mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
migrate subtitle style config to CSS declaration shape
- Flat style keys (fontFamily, fontSize, hoverTokenColor, etc.) consolidated into subtitleStyle.css, secondary.css, and subtitleSidebar.css objects - Hover token colors migrated to --subtitle-hover-token-color CSS custom properties - Plugin app-ping now checks result.status (0=running, 1=stopped) to avoid treating transient failures as stopped - Note-fields note type picker defaults to configured deck's note type before falling back to Kiku/Lapis - New migration for legacy ankiConnect N+1 config paths
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
getNodeValue,
|
||||
parseTree as parseJsoncTree,
|
||||
type Node as JsoncNode,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
import type { RawConfig } from '../types/config';
|
||||
import type { ConfigSettingsPatchOperation } from '../types/settings';
|
||||
import { DEFAULT_CONFIG } from './definitions';
|
||||
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
|
||||
|
||||
export type LegacyAnkiConnectNPlusOneMigrationResult =
|
||||
| {
|
||||
migrated: true;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}
|
||||
| {
|
||||
migrated: false;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
};
|
||||
|
||||
const LEGACY_N_PLUS_ONE_PATH_MAP = {
|
||||
highlightEnabled: 'ankiConnect.knownWords.highlightEnabled',
|
||||
refreshMinutes: 'ankiConnect.knownWords.refreshMinutes',
|
||||
matchMode: 'ankiConnect.knownWords.matchMode',
|
||||
decks: 'ankiConnect.knownWords.decks',
|
||||
knownWord: 'subtitleStyle.knownWordColor',
|
||||
nPlusOne: 'subtitleStyle.nPlusOneColor',
|
||||
} as const;
|
||||
|
||||
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
||||
return propertyNode.children?.[0]?.value;
|
||||
}
|
||||
|
||||
function propertyValue(propertyNode: JsoncNode | undefined): JsoncNode | undefined {
|
||||
return propertyNode?.children?.[1];
|
||||
}
|
||||
|
||||
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
|
||||
return node?.type === 'object' ? (node.children ?? []) : [];
|
||||
}
|
||||
|
||||
function findLastProperty(node: JsoncNode | undefined, key: string): JsoncNode | undefined {
|
||||
const matches = objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||
return matches.at(-1);
|
||||
}
|
||||
|
||||
function findProperties(node: JsoncNode | undefined, key: string): JsoncNode[] {
|
||||
return objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||
}
|
||||
|
||||
function findValueAtPath(root: JsoncNode | undefined, path: string): JsoncNode | undefined {
|
||||
let node = root;
|
||||
for (const segment of path.split('.')) {
|
||||
node = propertyValue(findLastProperty(node, segment));
|
||||
if (!node) return undefined;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function hasPath(root: JsoncNode | undefined, path: string): boolean {
|
||||
return findValueAtPath(root, path) !== undefined;
|
||||
}
|
||||
|
||||
function normalizeLegacyDecks(value: unknown): unknown {
|
||||
if (!Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
|
||||
const decks = value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const normalized: Record<string, string[]> = {};
|
||||
for (const deck of new Set(decks)) {
|
||||
normalized[deck] = defaultFields;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
||||
operations: ConfigSettingsPatchOperation[];
|
||||
hasLegacy: boolean;
|
||||
} {
|
||||
const operations: ConfigSettingsPatchOperation[] = [];
|
||||
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
|
||||
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
|
||||
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
|
||||
if (nPlusOneObjects.length === 0) {
|
||||
return { operations, hasLegacy: false };
|
||||
}
|
||||
|
||||
const canonicalNPlusOneValues = new Map<string, unknown>();
|
||||
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
|
||||
let hasLegacy = false;
|
||||
|
||||
for (const nPlusOne of nPlusOneObjects) {
|
||||
for (const property of objectProperties(nPlusOne)) {
|
||||
const key = propertyKey(property);
|
||||
if (!key) continue;
|
||||
const valueNode = propertyValue(property);
|
||||
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
||||
if (key === 'enabled' || key === 'minSentenceWords') {
|
||||
canonicalNPlusOneValues.set(key, value);
|
||||
continue;
|
||||
}
|
||||
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
|
||||
hasLegacy = true;
|
||||
legacyValues.set(key as keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nPlusOneObjects.length > 1) {
|
||||
for (const [key, value] of canonicalNPlusOneValues) {
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path: `ankiConnect.nPlusOne.${key}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, path] of Object.entries(LEGACY_N_PLUS_ONE_PATH_MAP) as Array<
|
||||
[keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, string]
|
||||
>) {
|
||||
if (!legacyValues.has(key)) continue;
|
||||
if (!hasPath(root, path)) {
|
||||
const value =
|
||||
key === 'decks' ? normalizeLegacyDecks(legacyValues.get(key)) : legacyValues.get(key);
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path,
|
||||
value,
|
||||
});
|
||||
}
|
||||
operations.push({
|
||||
op: 'reset',
|
||||
path: `ankiConnect.nPlusOne.${key}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { operations, hasLegacy };
|
||||
}
|
||||
|
||||
export function applyLegacyAnkiConnectNPlusOneMigrationToContent(options: {
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}): LegacyAnkiConnectNPlusOneMigrationResult {
|
||||
const errors: ParseError[] = [];
|
||||
const root = parseJsoncTree(options.content || '{}', errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
if (!root || errors.length > 0) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const { operations, hasLegacy } = buildLegacyNPlusOneMigrationOperations(root);
|
||||
if (operations.length === 0 && !hasLegacy) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: options.content,
|
||||
operations,
|
||||
previousWarnings: [],
|
||||
});
|
||||
if (!result.ok) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: true,
|
||||
content: result.content,
|
||||
rawConfig: result.rawConfig,
|
||||
};
|
||||
}
|
||||
+131
-13
@@ -12,16 +12,44 @@ import {
|
||||
} from './definitions';
|
||||
import { parseConfigContent } from './parse';
|
||||
import { generateConfigTemplate } from './template';
|
||||
import {
|
||||
buildSubtitleCssDeclarationObject,
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssPath,
|
||||
type SubtitleCssScope,
|
||||
} from '../settings/subtitle-style-css';
|
||||
|
||||
const DEFAULT_SUBTITLE_FONT_FAMILY =
|
||||
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
|
||||
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
|
||||
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record<string, string> {
|
||||
const values: Record<string, unknown> = {
|
||||
[getSubtitleCssPath(scope)]: getValueAtPath(DEFAULT_CONFIG, getSubtitleCssPath(scope)),
|
||||
};
|
||||
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||
values[path] = getValueAtPath(DEFAULT_CONFIG, path);
|
||||
}
|
||||
return buildSubtitleCssDeclarationObject(scope, values);
|
||||
}
|
||||
|
||||
test('loads defaults when config is missing', () => {
|
||||
const dir = makeTempDir();
|
||||
const service = new ConfigService(dir);
|
||||
@@ -203,6 +231,8 @@ test('migrates legacy subtitle appearance options into css declaration objects o
|
||||
"subtitleStyle": {
|
||||
"fontSize": 42,
|
||||
"fontColor": "#ffffff",
|
||||
"hoverTokenColor": "#abcdef",
|
||||
"hoverTokenBackgroundColor": "transparent",
|
||||
"css": {
|
||||
"font-size": "44px",
|
||||
"text-wrap": "balance"
|
||||
@@ -230,6 +260,8 @@ test('migrates legacy subtitle appearance options into css declaration objects o
|
||||
subtitleStyle: {
|
||||
fontSize?: unknown;
|
||||
fontColor?: unknown;
|
||||
hoverTokenColor?: unknown;
|
||||
hoverTokenBackgroundColor?: unknown;
|
||||
css?: Record<string, string>;
|
||||
secondary?: {
|
||||
fontSize?: unknown;
|
||||
@@ -249,10 +281,14 @@ test('migrates legacy subtitle appearance options into css declaration objects o
|
||||
assert.deepEqual(parsed.subtitleStyle.css, {
|
||||
color: '#ffffff',
|
||||
'font-size': '44px',
|
||||
'--subtitle-hover-token-color': '#abcdef',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
'text-wrap': 'balance',
|
||||
});
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenColor'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenBackgroundColor'), false);
|
||||
assert.deepEqual(parsed.subtitleStyle.secondary?.css, {
|
||||
color: '#bbbbbb',
|
||||
'font-size': '28px',
|
||||
@@ -2004,7 +2040,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => {
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
});
|
||||
|
||||
test('validates legacy ankiConnect knownWords and n+1 color values', () => {
|
||||
test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
@@ -2027,14 +2063,15 @@ test('validates legacy ankiConnect knownWords and n+1 color values', () => {
|
||||
|
||||
assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor);
|
||||
assert.equal(config.subtitleStyle.knownWordColor, DEFAULT_CONFIG.subtitleStyle.knownWordColor);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
|
||||
assert.ok(warnings.every((warning) => warning.path !== 'ankiConnect.nPlusOne.nPlusOne'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
|
||||
});
|
||||
|
||||
test('maps legacy ankiConnect knownWords and n+1 color values to subtitleStyle', () => {
|
||||
test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
configPath,
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
@@ -2053,12 +2090,21 @@ test('maps legacy ankiConnect knownWords and n+1 color values to subtitleStyle',
|
||||
|
||||
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
|
||||
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||
ankiConnect: { nPlusOne?: Record<string, unknown> };
|
||||
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
|
||||
};
|
||||
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
|
||||
});
|
||||
|
||||
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
|
||||
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
configPath,
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
@@ -2076,6 +2122,13 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||
ankiConnect: {
|
||||
knownWords: Record<string, unknown>;
|
||||
nPlusOne?: Record<string, unknown>;
|
||||
};
|
||||
subtitleStyle: { knownWordColor?: string };
|
||||
};
|
||||
|
||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
@@ -2085,16 +2138,53 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
|
||||
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
});
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.equal(parsed.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(parsed.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
assert.equal(parsed.ankiConnect.knownWords.matchMode, 'surface');
|
||||
assert.deepEqual(parsed.ankiConnect.knownWords.decks, {
|
||||
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
});
|
||||
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.decks' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.knownWord',
|
||||
['highlightEnabled', 'refreshMinutes', 'matchMode', 'decks', 'knownWord'].every(
|
||||
(key) => !Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, key),
|
||||
),
|
||||
);
|
||||
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
|
||||
});
|
||||
|
||||
test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"enabled": true,
|
||||
"minSentenceWords": 3
|
||||
},
|
||||
"knownWords": {
|
||||
"highlightEnabled": true
|
||||
},
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": "3"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||
ankiConnect: { nPlusOne: Record<string, unknown> };
|
||||
};
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
|
||||
});
|
||||
|
||||
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||
@@ -2543,6 +2633,34 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('template generator uses settings CSS declaration paths for appearance fields', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
const parsed = parseConfigContent('config.example.jsonc', output);
|
||||
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleStyle.css'),
|
||||
buildDefaultSubtitleCssDeclarations('primary'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleStyle.secondary.css'),
|
||||
buildDefaultSubtitleCssDeclarations('secondary'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleSidebar.css'),
|
||||
buildDefaultSubtitleCssDeclarations('sidebar'),
|
||||
);
|
||||
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||
assert.equal(
|
||||
getValueAtPath(parsed, path),
|
||||
undefined,
|
||||
`${path} should be represented by ${getSubtitleCssPath(scope)} in the generated template`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('template generator shows built-in default keybindings in the keybindings array', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
const parsed = parseConfigContent('config.example.jsonc', output) as {
|
||||
|
||||
@@ -124,5 +124,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
notificationType: 'system',
|
||||
channel: 'stable',
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
auto_start_overlay: true,
|
||||
};
|
||||
|
||||
@@ -654,7 +654,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
|
||||
|
||||
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
||||
const legacyNPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
|
||||
if (knownWordsHighlightEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
|
||||
} else if (knownWordsConfig.highlightEnabled !== undefined) {
|
||||
@@ -666,23 +665,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||
} else if (legacyNPlusOneHighlightEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled = legacyNPlusOneHighlightEnabled;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.highlightEnabled',
|
||||
nPlusOneConfig.highlightEnabled,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
|
||||
);
|
||||
} else if (nPlusOneConfig.highlightEnabled !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.highlightEnabled',
|
||||
nPlusOneConfig.highlightEnabled,
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||
} else {
|
||||
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
|
||||
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
|
||||
@@ -701,15 +683,10 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
|
||||
const legacyNPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
|
||||
const hasValidKnownWordsRefreshMinutes =
|
||||
knownWordsRefreshMinutes !== undefined &&
|
||||
Number.isInteger(knownWordsRefreshMinutes) &&
|
||||
knownWordsRefreshMinutes > 0;
|
||||
const hasValidLegacyNPlusOneRefreshMinutes =
|
||||
legacyNPlusOneRefreshMinutes !== undefined &&
|
||||
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
|
||||
legacyNPlusOneRefreshMinutes > 0;
|
||||
if (knownWordsRefreshMinutes !== undefined) {
|
||||
if (hasValidKnownWordsRefreshMinutes) {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
|
||||
@@ -723,25 +700,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||
}
|
||||
} else if (legacyNPlusOneRefreshMinutes !== undefined) {
|
||||
if (hasValidLegacyNPlusOneRefreshMinutes) {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyNPlusOneRefreshMinutes;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.refreshMinutes',
|
||||
nPlusOneConfig.refreshMinutes,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
|
||||
);
|
||||
} else {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.refreshMinutes',
|
||||
nPlusOneConfig.refreshMinutes,
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
||||
'Expected a positive integer.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||
}
|
||||
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
|
||||
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
|
||||
const hasValidLegacyRefreshMinutes =
|
||||
@@ -828,12 +786,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
|
||||
const legacyNPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
|
||||
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
|
||||
const hasValidKnownWordsMatchMode =
|
||||
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
|
||||
const hasValidLegacyNPlusOneMatchMode =
|
||||
legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
|
||||
const hasValidLegacyMatchMode =
|
||||
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
|
||||
if (hasValidKnownWordsMatchMode) {
|
||||
@@ -847,25 +802,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.matchMode =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||
} else if (legacyNPlusOneMatchMode !== undefined) {
|
||||
if (hasValidLegacyNPlusOneMatchMode) {
|
||||
context.resolved.ankiConnect.knownWords.matchMode = legacyNPlusOneMatchMode;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.matchMode',
|
||||
nPlusOneConfig.matchMode,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
|
||||
);
|
||||
} else {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.matchMode',
|
||||
nPlusOneConfig.matchMode,
|
||||
context.resolved.ankiConnect.knownWords.matchMode,
|
||||
"Expected 'headword' or 'surface'.",
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.matchMode =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||
}
|
||||
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
|
||||
if (hasValidLegacyMatchMode) {
|
||||
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
|
||||
@@ -897,7 +833,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
'Word Reading',
|
||||
];
|
||||
const knownWordsDecks = knownWordsConfig.decks;
|
||||
const legacyNPlusOneDecks = nPlusOneConfig.decks;
|
||||
if (isObject(knownWordsDecks)) {
|
||||
const resolved: Record<string, string[]> = {};
|
||||
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
|
||||
@@ -941,54 +876,14 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.ankiConnect.knownWords.decks,
|
||||
'Expected an object mapping deck names to field arrays.',
|
||||
);
|
||||
} else if (Array.isArray(legacyNPlusOneDecks)) {
|
||||
const normalized = legacyNPlusOneDecks
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
const resolved: Record<string, string[]> = {};
|
||||
for (const deck of new Set(normalized)) {
|
||||
resolved[deck] = DEFAULT_FIELDS;
|
||||
}
|
||||
context.resolved.ankiConnect.knownWords.decks = resolved;
|
||||
if (normalized.length > 0) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.decks',
|
||||
legacyNPlusOneDecks,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.decks with object format',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
|
||||
? (context.src.subtitleStyle as Record<string, unknown>)
|
||||
: {};
|
||||
const hasCanonicalNPlusOneColor = rawSubtitleStyle.nPlusOneColor !== undefined;
|
||||
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
|
||||
|
||||
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
|
||||
if (nPlusOneHighlightColor !== undefined) {
|
||||
if (!hasCanonicalNPlusOneColor) {
|
||||
context.resolved.subtitleStyle.nPlusOneColor = nPlusOneHighlightColor;
|
||||
}
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.nPlusOne',
|
||||
nPlusOneConfig.nPlusOne,
|
||||
context.resolved.subtitleStyle.nPlusOneColor,
|
||||
'Legacy key is deprecated; use subtitleStyle.nPlusOneColor',
|
||||
);
|
||||
} else if (nPlusOneConfig.nPlusOne !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.nPlusOne',
|
||||
nPlusOneConfig.nPlusOne,
|
||||
context.resolved.subtitleStyle.nPlusOneColor,
|
||||
'Expected a hex color value.',
|
||||
);
|
||||
}
|
||||
|
||||
const knownWordsColor = asColor(knownWordsConfig.color);
|
||||
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
|
||||
if (knownWordsColor !== undefined) {
|
||||
if (!hasCanonicalKnownWordColor) {
|
||||
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
|
||||
@@ -1006,23 +901,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.subtitleStyle.knownWordColor,
|
||||
'Expected a hex color value.',
|
||||
);
|
||||
} else if (legacyNPlusOneKnownWordColor !== undefined) {
|
||||
if (!hasCanonicalKnownWordColor) {
|
||||
context.resolved.subtitleStyle.knownWordColor = legacyNPlusOneKnownWordColor;
|
||||
}
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.knownWord',
|
||||
nPlusOneConfig.knownWord,
|
||||
context.resolved.subtitleStyle.knownWordColor,
|
||||
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
|
||||
);
|
||||
} else if (nPlusOneConfig.knownWord !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.knownWord',
|
||||
nPlusOneConfig.knownWord,
|
||||
context.resolved.subtitleStyle.knownWordColor,
|
||||
'Expected a hex color value.',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -25,6 +25,23 @@ function asCssDeclarations(value: unknown): Record<string, string> | undefined {
|
||||
return declarations;
|
||||
}
|
||||
|
||||
const SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY = '--subtitle-hover-token-color';
|
||||
const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-background-color';
|
||||
|
||||
function applySubtitleHoverTokenCssCompatibility(
|
||||
subtitleStyle: ResolvedConfig['subtitleStyle'],
|
||||
): void {
|
||||
const hoverTokenColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY];
|
||||
if (hoverTokenColor !== undefined) {
|
||||
subtitleStyle.hoverTokenColor = hoverTokenColor;
|
||||
}
|
||||
|
||||
const hoverTokenBackgroundColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY];
|
||||
if (hoverTokenBackgroundColor !== undefined) {
|
||||
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
@@ -349,6 +366,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
applySubtitleHoverTokenCssCompatibility(resolved.subtitleStyle);
|
||||
|
||||
const nameMatchColor = asColor(
|
||||
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
|
||||
);
|
||||
|
||||
@@ -34,6 +34,8 @@ test('subtitleStyle css declarations accept string declaration maps and warn on
|
||||
css: {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
'--subtitle-hover-token-color': '#c6a0f6',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
},
|
||||
secondary: {
|
||||
css: {
|
||||
@@ -46,7 +48,11 @@ test('subtitleStyle css declarations accept string declaration maps and warn on
|
||||
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
'--subtitle-hover-token-color': '#c6a0f6',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
});
|
||||
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenColor, '#c6a0f6');
|
||||
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
||||
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
|
||||
'text-transform': 'uppercase',
|
||||
});
|
||||
|
||||
+27
-16
@@ -4,6 +4,7 @@ import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/con
|
||||
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
|
||||
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
|
||||
import { resolveConfig } from './resolve';
|
||||
import { applyLegacyAnkiConnectNPlusOneMigrationToContent } from './anki-connect-nplusone-migration';
|
||||
import { applyLegacySubtitleStyleCssMigrationToContent } from './subtitle-style-css-migration';
|
||||
|
||||
export type ReloadConfigStrictResult =
|
||||
@@ -51,7 +52,7 @@ export class ConfigService {
|
||||
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
|
||||
}
|
||||
this.applyResolvedConfig(
|
||||
this.migrateLegacySubtitleStyleCssConfig(loadResult.config, loadResult.path),
|
||||
this.migrateLegacyConfig(loadResult.config, loadResult.path),
|
||||
loadResult.path,
|
||||
);
|
||||
}
|
||||
@@ -74,10 +75,7 @@ export class ConfigService {
|
||||
|
||||
reloadConfig(): ResolvedConfig {
|
||||
const { config, path: configPath } = loadRawConfig(this.configPaths);
|
||||
return this.applyResolvedConfig(
|
||||
this.migrateLegacySubtitleStyleCssConfig(config, configPath),
|
||||
configPath,
|
||||
);
|
||||
return this.applyResolvedConfig(this.migrateLegacyConfig(config, configPath), configPath);
|
||||
}
|
||||
|
||||
reloadConfigStrict(): ReloadConfigStrictResult {
|
||||
@@ -88,7 +86,7 @@ export class ConfigService {
|
||||
|
||||
const { config, path: configPath } = loadResult;
|
||||
const resolvedConfig = this.applyResolvedConfig(
|
||||
this.migrateLegacySubtitleStyleCssConfig(config, configPath),
|
||||
this.migrateLegacyConfig(config, configPath),
|
||||
configPath,
|
||||
);
|
||||
return {
|
||||
@@ -124,22 +122,35 @@ export class ConfigService {
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
private migrateLegacySubtitleStyleCssConfig(config: RawConfig, configPath: string): RawConfig {
|
||||
private migrateLegacyConfig(config: RawConfig, configPath: string): RawConfig {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const migration = applyLegacySubtitleStyleCssMigrationToContent({
|
||||
content,
|
||||
rawConfig: config,
|
||||
});
|
||||
if (!migration.migrated) {
|
||||
return config;
|
||||
let content = fs.readFileSync(configPath, 'utf-8');
|
||||
let rawConfig = config;
|
||||
let migrated = false;
|
||||
for (const applyMigration of [
|
||||
applyLegacyAnkiConnectNPlusOneMigrationToContent,
|
||||
applyLegacySubtitleStyleCssMigrationToContent,
|
||||
]) {
|
||||
const migration = applyMigration({
|
||||
content,
|
||||
rawConfig,
|
||||
});
|
||||
if (!migration.migrated) {
|
||||
continue;
|
||||
}
|
||||
content = migration.content;
|
||||
rawConfig = migration.rawConfig;
|
||||
migrated = true;
|
||||
}
|
||||
fs.writeFileSync(configPath, migration.content, 'utf-8');
|
||||
return migration.rawConfig;
|
||||
if (!migrated) {
|
||||
return rawConfig;
|
||||
}
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
return rawConfig;
|
||||
} catch {
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,39 @@ test('applyConfigSettingsPatchToContent preserves JSONC comments while setting n
|
||||
assert.equal(parsed.subtitleStyle.fontSize, 35);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent updates effective duplicate object path', () => {
|
||||
const input = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"enabled": true
|
||||
},
|
||||
"knownWords": {
|
||||
"highlightEnabled": true
|
||||
},
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": 3
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: input,
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'ankiConnect.nPlusOne.enabled',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const parsed = parse(result.content);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
|
||||
const input = `{
|
||||
"subtitleStyle": {
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
applyEdits,
|
||||
modify,
|
||||
parse as parseJsonc,
|
||||
parseTree as parseJsoncTree,
|
||||
type Edit,
|
||||
type Node as JsoncNode,
|
||||
type FormattingOptions,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
@@ -91,6 +94,7 @@ function normalizeContent(content: string): string {
|
||||
}
|
||||
|
||||
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
|
||||
content = removeDuplicatePropertiesAlongPath(content, operation.path);
|
||||
const edits = modify(
|
||||
content,
|
||||
pathToSegments(operation.path),
|
||||
@@ -103,6 +107,109 @@ function applySingleOperation(content: string, operation: ConfigSettingsPatchOpe
|
||||
return applyEdits(content, edits);
|
||||
}
|
||||
|
||||
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
||||
return propertyNode.children?.[0]?.value;
|
||||
}
|
||||
|
||||
function propertyValue(propertyNode: JsoncNode): JsoncNode | undefined {
|
||||
return propertyNode.children?.[1];
|
||||
}
|
||||
|
||||
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
|
||||
return node?.type === 'object' ? (node.children ?? []) : [];
|
||||
}
|
||||
|
||||
function isWhitespace(value: string | undefined): boolean {
|
||||
return value === ' ' || value === '\t' || value === '\r' || value === '\n';
|
||||
}
|
||||
|
||||
function nextNonWhitespaceOffset(content: string, offset: number): number {
|
||||
let index = offset;
|
||||
while (index < content.length && isWhitespace(content[index])) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function previousNonWhitespaceOffset(content: string, offset: number): number {
|
||||
let index = offset;
|
||||
while (index >= 0 && isWhitespace(content[index])) {
|
||||
index -= 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function lineStartOffset(content: string, offset: number): number {
|
||||
return content.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
|
||||
}
|
||||
|
||||
function removalEditForProperty(content: string, propertyNode: JsoncNode): Edit {
|
||||
let offset = propertyNode.offset;
|
||||
let end = propertyNode.offset + propertyNode.length;
|
||||
const next = nextNonWhitespaceOffset(content, end);
|
||||
|
||||
if (content[next] === ',') {
|
||||
end = next + 1;
|
||||
const lineStart = lineStartOffset(content, offset);
|
||||
if (/^[ \t]*$/.test(content.slice(lineStart, offset))) {
|
||||
offset = lineStart;
|
||||
}
|
||||
} else {
|
||||
const previous = previousNonWhitespaceOffset(content, offset - 1);
|
||||
if (content[previous] === ',') {
|
||||
offset = previous;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
offset,
|
||||
length: Math.max(0, end - offset),
|
||||
content: '',
|
||||
};
|
||||
}
|
||||
|
||||
function collectDuplicatePropertyRemovalEdits(content: string, path: string): Edit[] {
|
||||
const errors: ParseError[] = [];
|
||||
let node = parseJsoncTree(content, errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
if (!node || errors.length > 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const edits: Edit[] = [];
|
||||
for (const segment of pathToSegments(path)) {
|
||||
const matches = objectProperties(node).filter((property) => propertyKey(property) === segment);
|
||||
if (matches.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const duplicate of matches.slice(0, -1)) {
|
||||
edits.push(removalEditForProperty(content, duplicate));
|
||||
}
|
||||
|
||||
node = propertyValue(matches[matches.length - 1]!);
|
||||
}
|
||||
|
||||
return edits;
|
||||
}
|
||||
|
||||
function applyRemovalEdits(content: string, edits: Edit[]): string {
|
||||
return [...edits]
|
||||
.sort((left, right) => right.offset - left.offset)
|
||||
.reduce(
|
||||
(current, edit) =>
|
||||
`${current.slice(0, edit.offset)}${edit.content}${current.slice(edit.offset + edit.length)}`,
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
function removeDuplicatePropertiesAlongPath(content: string, path: string): string {
|
||||
const edits = collectDuplicatePropertyRemovalEdits(content, path);
|
||||
return edits.length > 0 ? applyRemovalEdits(content, edits) : content;
|
||||
}
|
||||
|
||||
function collectModifiedWarnings(
|
||||
warnings: ConfigValidationWarning[],
|
||||
operations: ConfigSettingsPatchOperation[],
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
|
||||
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
const STARTUP_MIGRATION_EXCLUDED_PATHS = new Set([
|
||||
'subtitleStyle.hoverTokenColor',
|
||||
'subtitleStyle.hoverTokenBackgroundColor',
|
||||
]);
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
export type LegacySubtitleStyleCssMigrationResult =
|
||||
| {
|
||||
@@ -54,6 +51,16 @@ function hasPath(root: unknown, path: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMigratableLegacySubtitleCssValue(path: string, value: unknown): boolean {
|
||||
if (path === 'subtitleStyle.hoverTokenColor') {
|
||||
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim());
|
||||
}
|
||||
if (path === 'subtitleStyle.hoverTokenBackgroundColor') {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildLegacySubtitleStyleCssMigrationOperations(
|
||||
rawConfig: RawConfig,
|
||||
): ConfigSettingsPatchOperation[] {
|
||||
@@ -66,7 +73,8 @@ export function buildLegacySubtitleStyleCssMigrationOperations(
|
||||
};
|
||||
const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter(
|
||||
(legacyPath) =>
|
||||
!STARTUP_MIGRATION_EXCLUDED_PATHS.has(legacyPath) && hasPath(rawConfig, legacyPath),
|
||||
hasPath(rawConfig, legacyPath) &&
|
||||
isMigratableLegacySubtitleCssValue(legacyPath, getValueAtPath(rawConfig, legacyPath)),
|
||||
);
|
||||
if (legacyPaths.length === 0) continue;
|
||||
|
||||
|
||||
@@ -6,11 +6,18 @@ import {
|
||||
DEFAULT_KEYBINDINGS,
|
||||
deepCloneConfig,
|
||||
} from './definitions';
|
||||
import {
|
||||
buildSubtitleCssDeclarationObject,
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssPath,
|
||||
type SubtitleCssScope,
|
||||
} from '../settings/subtitle-style-css';
|
||||
|
||||
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
|
||||
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
|
||||
);
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
|
||||
function normalizeCommentText(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
|
||||
@@ -18,7 +25,9 @@ function normalizeCommentText(value: string): string {
|
||||
|
||||
function humanizeKey(key: string): string {
|
||||
const spaced = key
|
||||
.replace(/^--/, '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.toLowerCase();
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
@@ -42,6 +51,62 @@ function buildInlineOptionComment(path: string, value: unknown): string {
|
||||
return description;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function setValueAtPath(root: unknown, path: string, value: unknown): void {
|
||||
const segments = path.split('.').filter(Boolean);
|
||||
let current = root;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (!isRecord(current)) return;
|
||||
if (index === segments.length - 1) {
|
||||
current[segment] = value;
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
}
|
||||
|
||||
function deleteValueAtPath(root: unknown, path: string): void {
|
||||
const segments = path.split('.').filter(Boolean);
|
||||
let current = root;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (!isRecord(current)) return;
|
||||
if (index === segments.length - 1) {
|
||||
delete current[segment];
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
}
|
||||
|
||||
function foldSubtitleCssManagedDefaults(templateConfig: ResolvedConfig): void {
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
const cssPath = getSubtitleCssPath(scope);
|
||||
const values: Record<string, unknown> = {
|
||||
[cssPath]: getValueAtPath(templateConfig, cssPath),
|
||||
};
|
||||
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
|
||||
for (const managedPath of managedPaths) {
|
||||
values[managedPath] = getValueAtPath(templateConfig, managedPath);
|
||||
}
|
||||
setValueAtPath(templateConfig, cssPath, buildSubtitleCssDeclarationObject(scope, values));
|
||||
for (const managedPath of managedPaths) {
|
||||
deleteValueAtPath(templateConfig, managedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderValue(value: unknown, indent = 0, path = ''): string {
|
||||
const pad = ' '.repeat(indent);
|
||||
const nextPad = ' '.repeat(indent + 2);
|
||||
@@ -106,6 +171,7 @@ function renderSection(
|
||||
|
||||
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
|
||||
const templateConfig = deepCloneConfig(config);
|
||||
foldSubtitleCssManagedDefaults(templateConfig);
|
||||
if (templateConfig.keybindings.length === 0) {
|
||||
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
|
||||
key: binding.key,
|
||||
|
||||
@@ -11,7 +11,8 @@ type WindowTrackerStub = {
|
||||
isTargetWindowMinimized?: () => boolean;
|
||||
};
|
||||
|
||||
function createMainWindowRecorder() {
|
||||
function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {}) {
|
||||
const emitShowImmediately = options.emitShowImmediately ?? true;
|
||||
const calls: string[] = [];
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
let visible = false;
|
||||
@@ -25,6 +26,10 @@ function createMainWindowRecorder() {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
const emitShow = (): void => {
|
||||
visible = true;
|
||||
emit('show');
|
||||
};
|
||||
const window = {
|
||||
webContents: {},
|
||||
isDestroyed: () => false,
|
||||
@@ -39,14 +44,16 @@ function createMainWindowRecorder() {
|
||||
calls.push('hide');
|
||||
},
|
||||
show: () => {
|
||||
visible = true;
|
||||
calls.push('show');
|
||||
emit('show');
|
||||
if (emitShowImmediately) {
|
||||
emitShow();
|
||||
}
|
||||
},
|
||||
showInactive: () => {
|
||||
visible = true;
|
||||
calls.push('show-inactive');
|
||||
emit('show');
|
||||
if (emitShowImmediately) {
|
||||
emitShow();
|
||||
}
|
||||
},
|
||||
focus: () => {
|
||||
focused = true;
|
||||
@@ -81,6 +88,7 @@ function createMainWindowRecorder() {
|
||||
window,
|
||||
calls,
|
||||
getOpacity: () => opacity,
|
||||
emitShow,
|
||||
setContentReady: (nextContentReady: boolean) => {
|
||||
contentReady = nextContentReady;
|
||||
(
|
||||
@@ -267,6 +275,50 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay queues only one first-show bounds refresh', () => {
|
||||
const { window, calls, emitShow } = createMainWindowRecorder({ emitShowImmediately: false });
|
||||
let width = 1280;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width, height: 720 }),
|
||||
};
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: (geometry: { width: number }) => {
|
||||
calls.push(`update-bounds:${geometry.width}`);
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
run();
|
||||
width = 1440;
|
||||
run();
|
||||
emitShow();
|
||||
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call.startsWith('update-bounds:')),
|
||||
['update-bounds:1280', 'update-bounds:1440', 'update-bounds:1440'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -8,6 +8,7 @@ const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
|
||||
BrowserWindow,
|
||||
ReturnType<typeof setTimeout>
|
||||
>();
|
||||
const pendingFirstShowBoundsRefreshGeometry = new WeakMap<BrowserWindow, WindowGeometry>();
|
||||
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
||||
const opacityCapableWindow = window as BrowserWindow & {
|
||||
setOpacity?: (opacity: number) => void;
|
||||
@@ -279,11 +280,20 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (pendingFirstShowBoundsRefreshGeometry.has(mainWindow)) {
|
||||
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
|
||||
return;
|
||||
}
|
||||
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
|
||||
mainWindow.once('show', () => {
|
||||
const pendingGeometry = pendingFirstShowBoundsRefreshGeometry.get(mainWindow);
|
||||
pendingFirstShowBoundsRefreshGeometry.delete(mainWindow);
|
||||
if (mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
if (pendingGeometry) {
|
||||
args.updateVisibleOverlayBounds(pendingGeometry);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface TokenizerServiceDeps {
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
@@ -74,6 +75,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
@@ -88,6 +90,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
}
|
||||
|
||||
interface TokenizerAnnotationOptions {
|
||||
knownWordsEnabled: boolean;
|
||||
nPlusOneEnabled: boolean;
|
||||
jlptEnabled: boolean;
|
||||
nameMatchEnabled: boolean;
|
||||
@@ -119,18 +122,28 @@ function getKnownWordLookup(
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
): (text: string) => boolean {
|
||||
if (!options.nPlusOneEnabled) {
|
||||
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
|
||||
return () => false;
|
||||
}
|
||||
return deps.isKnownWord;
|
||||
}
|
||||
|
||||
function needsMecabPosEnrichment(options: TokenizerAnnotationOptions): boolean {
|
||||
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
|
||||
return (
|
||||
options.knownWordsEnabled ||
|
||||
options.nPlusOneEnabled ||
|
||||
options.jlptEnabled ||
|
||||
options.frequencyEnabled
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnyAnnotationEnabled(options: TokenizerAnnotationOptions): boolean {
|
||||
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
|
||||
return (
|
||||
options.knownWordsEnabled ||
|
||||
options.nPlusOneEnabled ||
|
||||
options.jlptEnabled ||
|
||||
options.frequencyEnabled
|
||||
);
|
||||
}
|
||||
|
||||
async function enrichTokensWithMecabAsync(
|
||||
@@ -211,6 +224,7 @@ export function createTokenizerDepsRuntime(
|
||||
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
||||
isKnownWord: options.isKnownWord,
|
||||
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
||||
getKnownWordsEnabled: options.getKnownWordsEnabled,
|
||||
getJlptLevel: options.getJlptLevel,
|
||||
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
||||
getJlptEnabled: options.getJlptEnabled,
|
||||
@@ -662,8 +676,12 @@ function applyFrequencyRanks(
|
||||
}
|
||||
|
||||
function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOptions {
|
||||
const nPlusOneEnabled = deps.getNPlusOneEnabled?.() !== false;
|
||||
return {
|
||||
nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false,
|
||||
knownWordsEnabled: deps.getKnownWordsEnabled
|
||||
? deps.getKnownWordsEnabled() !== false
|
||||
: nPlusOneEnabled,
|
||||
nPlusOneEnabled,
|
||||
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
||||
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
|
||||
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
||||
|
||||
@@ -56,6 +56,50 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
|
||||
assert.equal(surfaceResult[0]?.isKnown, false);
|
||||
});
|
||||
|
||||
test('annotateTokens marks known words when N+1 is disabled', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
||||
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
|
||||
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '私' || text === '猫',
|
||||
}),
|
||||
{ nPlusOneEnabled: false, knownWordsEnabled: true },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[1]?.isKnown, true);
|
||||
assert.equal(result[1]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[2]?.isKnown, false);
|
||||
assert.equal(result[2]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens hides known-word marks while still using known words for N+1', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
||||
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
|
||||
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '私' || text === '猫',
|
||||
}),
|
||||
{ nPlusOneEnabled: true, knownWordsEnabled: false, minSentenceWordsForNPlusOne: 3 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[1]?.isKnown, false);
|
||||
assert.equal(result[2]?.isKnown, false);
|
||||
assert.equal(result[2]?.isNPlusOneTarget, true);
|
||||
});
|
||||
|
||||
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface AnnotationStageDeps {
|
||||
}
|
||||
|
||||
export interface AnnotationStageOptions {
|
||||
knownWordsEnabled?: boolean;
|
||||
nPlusOneEnabled?: boolean;
|
||||
nameMatchEnabled?: boolean;
|
||||
jlptEnabled?: boolean;
|
||||
@@ -669,13 +670,16 @@ export function annotateTokens(
|
||||
): MergedToken[] {
|
||||
const pos1Exclusions = resolvePos1Exclusions(options);
|
||||
const pos2Exclusions = resolvePos2Exclusions(options);
|
||||
const knownWordsEnabled = options.knownWordsEnabled !== false;
|
||||
const nPlusOneEnabled = options.nPlusOneEnabled !== false;
|
||||
const nameMatchEnabled = options.nameMatchEnabled !== false;
|
||||
const frequencyEnabled = options.frequencyEnabled !== false;
|
||||
const jlptEnabled = options.jlptEnabled !== false;
|
||||
const shouldComputeKnownStatus = knownWordsEnabled || nPlusOneEnabled;
|
||||
const nPlusOneKnownStatuses: boolean[] = [];
|
||||
|
||||
// Single pass: compute known word status, frequency filtering, and JLPT level together
|
||||
const annotated = tokens.map((token) => {
|
||||
const annotated = tokens.map((token, index) => {
|
||||
if (
|
||||
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
|
||||
pos1Exclusions,
|
||||
@@ -686,6 +690,7 @@ export function annotateTokens(
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
});
|
||||
nPlusOneKnownStatuses[index] = false;
|
||||
return {
|
||||
...strippedToken,
|
||||
isKnown: false,
|
||||
@@ -693,9 +698,10 @@ export function annotateTokens(
|
||||
}
|
||||
|
||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||
const isKnown = nPlusOneEnabled
|
||||
const isKnownForMatching = shouldComputeKnownStatus
|
||||
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
|
||||
: false;
|
||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||
|
||||
const frequencyRank =
|
||||
frequencyEnabled && !prioritizedNameMatch
|
||||
@@ -709,7 +715,7 @@ export function annotateTokens(
|
||||
|
||||
return {
|
||||
...token,
|
||||
isKnown,
|
||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
|
||||
frequencyRank,
|
||||
jlptLevel,
|
||||
@@ -728,13 +734,21 @@ export function annotateTokens(
|
||||
? minSentenceWordsForNPlusOne
|
||||
: 3;
|
||||
|
||||
const nPlusOneMarked = markNPlusOneTargets(
|
||||
annotated,
|
||||
sanitizedMinSentenceWordsForNPlusOne,
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
options.sourceText,
|
||||
);
|
||||
const nPlusOneMarked = nPlusOneEnabled
|
||||
? markNPlusOneTargets(
|
||||
annotated.map((token, index) => ({
|
||||
...token,
|
||||
isKnown: nPlusOneKnownStatuses[index] ?? false,
|
||||
})),
|
||||
sanitizedMinSentenceWordsForNPlusOne,
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
options.sourceText,
|
||||
).map((token, index) => ({
|
||||
...annotated[index]!,
|
||||
isNPlusOneTarget: token.isNPlusOneTarget,
|
||||
}))
|
||||
: annotated;
|
||||
|
||||
if (!nameMatchEnabled) {
|
||||
return nPlusOneMarked;
|
||||
|
||||
+13
-3
@@ -4270,6 +4270,11 @@ const {
|
||||
getKnownWordMatchMode: () =>
|
||||
appState.ankiIntegration?.getKnownWordMatchMode() ??
|
||||
getResolvedConfig().ankiConnect.knownWords.matchMode,
|
||||
getKnownWordsEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
||||
),
|
||||
getNPlusOneEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
@@ -5292,12 +5297,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
tokenizeCurrentSubtitle: async () =>
|
||||
resolveCurrentSubtitleForRenderer({
|
||||
tokenizeCurrentSubtitle: async () => {
|
||||
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
||||
return resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: appState.currentSubText,
|
||||
currentSubtitleData: appState.currentSubtitleData,
|
||||
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
|
||||
}),
|
||||
tokenizeSubtitle: tokenizeSubtitleForCurrent
|
||||
? (text) => tokenizeSubtitleForCurrent(text)
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||
getSubtitleSidebarSnapshot: async () => {
|
||||
|
||||
@@ -137,10 +137,13 @@ export function composeMpvRuntimeHandlers<
|
||||
const shouldInitializeMecabForAnnotations = (): boolean => {
|
||||
const nPlusOneEnabled =
|
||||
options.tokenizer.buildTokenizerDepsMainDeps.getNPlusOneEnabled?.() !== false;
|
||||
const knownWordsEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled
|
||||
? options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled() !== false
|
||||
: nPlusOneEnabled;
|
||||
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
||||
const frequencyEnabled =
|
||||
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
|
||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
};
|
||||
const shouldWarmupAnnotationDictionaries = (): boolean => {
|
||||
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
||||
|
||||
@@ -45,3 +45,16 @@ test('renderer current subtitle snapshot falls back to raw text for uncached sub
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.equal(payload.tokens, null);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot tokenizes uncached subtitles when tokenizer is available', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '新しい字幕',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '新' } as never] }),
|
||||
});
|
||||
|
||||
assert.equal(payload.text, '新しい字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
currentSubText: string;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
||||
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
|
||||
}): Promise<SubtitleData> {
|
||||
if (deps.currentSubtitleData?.text === deps.currentSubText) {
|
||||
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
|
||||
@@ -16,6 +17,11 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
});
|
||||
}
|
||||
|
||||
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
||||
if (tokenized) {
|
||||
return deps.withCurrentSubtitleTiming(tokenized);
|
||||
}
|
||||
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
|
||||
@@ -30,6 +30,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
||||
isKnownWord: (text) => text === 'known',
|
||||
recordLookup: (hit) => calls.push(`lookup:${hit}`),
|
||||
getKnownWordMatchMode: () => 'surface',
|
||||
getKnownWordsEnabled: () => true,
|
||||
getNPlusOneEnabled: () => true,
|
||||
getMinSentenceWordsForNPlusOne: () => 3,
|
||||
getJlptLevel: () => 'N2',
|
||||
@@ -47,6 +48,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
||||
deps.setYomitanParserWindow(null);
|
||||
deps.setYomitanParserReadyPromise(null);
|
||||
deps.setYomitanParserInitPromise(null);
|
||||
assert.equal(deps.getKnownWordsEnabled?.(), true);
|
||||
assert.equal(deps.getNPlusOneEnabled?.(), true);
|
||||
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
|
||||
assert.equal(deps.getNameMatchEnabled?.(), false);
|
||||
|
||||
@@ -38,6 +38,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
||||
return hit;
|
||||
},
|
||||
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
|
||||
...(deps.getKnownWordsEnabled
|
||||
? {
|
||||
getKnownWordsEnabled: () => deps.getKnownWordsEnabled!(),
|
||||
}
|
||||
: {}),
|
||||
...(deps.getNPlusOneEnabled
|
||||
? {
|
||||
getNPlusOneEnabled: () => deps.getNPlusOneEnabled!(),
|
||||
|
||||
@@ -2,17 +2,17 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as ankiControls from './settings-anki-controls';
|
||||
|
||||
test('note field model preference keeps a matching configured model before Kiku fallback', () => {
|
||||
test('note field model preference ignores configured sentence-card model before Kiku fallback', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
|
||||
'Lapis Morph',
|
||||
'Kiku',
|
||||
);
|
||||
});
|
||||
|
||||
test('note field model preference matches configured model case-insensitively', () => {
|
||||
test('note field model preference ignores configured sentence-card model case-insensitively', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'),
|
||||
'Lapis Morph',
|
||||
'Kiku',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,10 +28,10 @@ test('note field model preference does not treat partial Kiku matches as Kiku',
|
||||
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Mining'], ''), '');
|
||||
});
|
||||
|
||||
test('note field model preference accepts partial Lapis matches', () => {
|
||||
test('note field model preference does not treat partial Lapis matches as Lapis', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], ''),
|
||||
'Lapis Morph',
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -46,39 +46,25 @@ export function configureAnkiControls(options: { requestRender: () => void }): v
|
||||
requestRender = options.requestRender;
|
||||
}
|
||||
|
||||
export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void {
|
||||
const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel'];
|
||||
if (
|
||||
!state.noteFieldModelName &&
|
||||
!state.noteFieldModelNameManuallySelected &&
|
||||
typeof configuredNoteType === 'string'
|
||||
) {
|
||||
state.noteFieldModelName = configuredNoteType;
|
||||
}
|
||||
export function initializeAnkiControls(_values: Record<string, ConfigSettingsSnapshotValue>): void {
|
||||
state.noteFieldModelName = '';
|
||||
state.noteFieldModelNameManuallySelected = false;
|
||||
}
|
||||
|
||||
export function selectPreferredNoteFieldModelName(
|
||||
modelNames: readonly string[],
|
||||
currentModelName = '',
|
||||
): string {
|
||||
const normalizedCurrentModelName = currentModelName.trim().toLowerCase();
|
||||
if (normalizedCurrentModelName) {
|
||||
const currentModel = modelNames.find(
|
||||
(name) => name.toLowerCase() === normalizedCurrentModelName,
|
||||
);
|
||||
if (currentModel) {
|
||||
return currentModel;
|
||||
}
|
||||
}
|
||||
void currentModelName;
|
||||
|
||||
const exactKiku = modelNames.find((name) => name.toLowerCase() === 'kiku');
|
||||
const exactKiku = modelNames.find((name) => name.trim().toLowerCase() === 'kiku');
|
||||
if (exactKiku) {
|
||||
return exactKiku;
|
||||
}
|
||||
|
||||
const lapis = modelNames.find((name) => name.toLowerCase().includes('lapis'));
|
||||
if (lapis) {
|
||||
return lapis;
|
||||
const exactLapis = modelNames.find((name) => name.trim().toLowerCase() === 'lapis');
|
||||
if (exactLapis) {
|
||||
return exactLapis;
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
@@ -87,7 +87,6 @@ export interface AnkiConnectConfig {
|
||||
};
|
||||
nPlusOne?: {
|
||||
enabled?: boolean;
|
||||
nPlusOne?: string;
|
||||
minSentenceWords?: number;
|
||||
};
|
||||
behavior?: {
|
||||
|
||||
Reference in New Issue
Block a user