From 92c1557e4696ef258d1a3e1c46feb5e556558db7 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 15 Mar 2026 22:07:47 -0700 Subject: [PATCH] refactor: split known words config from n-plus-one --- src/anki-integration/known-word-cache.ts | 18 ++++- src/config/config.test.ts | 16 ++-- .../definitions/defaults-integrations.ts | 2 +- .../definitions/options-integrations.ts | 4 +- src/config/resolve/anki-connect.test.ts | 25 ++++-- src/config/resolve/anki-connect.ts | 80 +++++++++---------- src/types.ts | 4 +- 7 files changed, 87 insertions(+), 62 deletions(-) diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index abf3ebf..8fedc15 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -217,10 +217,8 @@ export class KnownWordCacheManager { private getKnownWordDecks(): string[] { const configuredDecks = this.deps.getConfig().knownWords?.decks; - if (Array.isArray(configuredDecks)) { - return configuredDecks - .map((deck) => (typeof deck === 'string' ? deck.trim() : '')) - .filter((deck) => deck.length > 0); + if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) { + return Object.keys(configuredDecks).map((d) => d.trim()).filter((d) => d.length > 0); } const deck = this.deps.getConfig().deck?.trim(); @@ -228,6 +226,18 @@ export class KnownWordCacheManager { } private getConfiguredFields(): string[] { + const configuredDecks = this.deps.getConfig().knownWords?.decks; + if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) { + const allFields = new Set(); + for (const fields of Object.values(configuredDecks)) { + if (Array.isArray(fields)) { + for (const f of fields) { + if (typeof f === 'string' && f.trim()) allFields.add(f.trim()); + } + } + } + if (allFields.size > 0) return [...allFields]; + } return ['Expression', 'Word', 'Reading', 'Word Reading']; } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index b83836f..564445f 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1585,7 +1585,10 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () assert.equal(config.ankiConnect.knownWords.highlightEnabled, true); assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90); assert.equal(config.ankiConnect.knownWords.matchMode, 'surface'); - assert.deepEqual(config.ankiConnect.knownWords.decks, ['Mining', 'Kaishi 1.5k']); + assert.deepEqual(config.ankiConnect.knownWords.decks, { + 'Mining': ['Expression', 'Word', 'Reading', 'Word Reading'], + 'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'], + }); assert.equal(config.ankiConnect.knownWords.color, '#a6da95'); assert.ok( warnings.some( @@ -1842,14 +1845,14 @@ test('ignores deprecated isLapis sentence-card field overrides', () => { ); }); -test('accepts valid ankiConnect knownWords deck list', () => { +test('accepts valid ankiConnect knownWords deck object', () => { const dir = makeTempDir(); fs.writeFileSync( path.join(dir, 'config.jsonc'), `{ "ankiConnect": { "knownWords": { - "decks": ["Deck One", "Deck Two"] + "decks": { "Deck One": ["Word", "Reading"], "Deck Two": ["Expression"] } } } }`, @@ -1859,7 +1862,10 @@ test('accepts valid ankiConnect knownWords deck list', () => { const service = new ConfigService(dir); const config = service.getConfig(); - assert.deepEqual(config.ankiConnect.knownWords.decks, ['Deck One', 'Deck Two']); + assert.deepEqual(config.ankiConnect.knownWords.decks, { + 'Deck One': ['Word', 'Reading'], + 'Deck Two': ['Expression'], + }); }); test('accepts valid ankiConnect tags list', () => { @@ -1918,7 +1924,7 @@ test('falls back to default when ankiConnect knownWords deck list is invalid', ( const config = service.getConfig(); const warnings = service.getWarnings(); - assert.deepEqual(config.ankiConnect.knownWords.decks, []); + assert.deepEqual(config.ankiConnect.knownWords.decks, {}); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks')); }); diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index a533c44..7ae21b5 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -54,7 +54,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< highlightEnabled: false, refreshMinutes: 1440, matchMode: 'headword', - decks: [], + decks: {}, color: '#a6da95', }, behavior: { diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 8569369..c219f5c 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -103,9 +103,9 @@ export function buildIntegrationConfigOptionRegistry( }, { path: 'ankiConnect.knownWords.decks', - kind: 'array', + kind: 'object', defaultValue: defaultConfig.ankiConnect.knownWords.decks, - description: 'Decks used for known-word cache scope. Supports one or more deck names.', + description: 'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.', }, { path: 'ankiConnect.nPlusOne.nPlusOne', diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts index 36a373f..44cf256 100644 --- a/src/config/resolve/anki-connect.test.ts +++ b/src/config/resolve/anki-connect.test.ts @@ -50,17 +50,30 @@ test('normalizes ankiConnect tags by trimming and deduping', () => { ); }); -test('warns and falls back for invalid knownWords.decks entries', () => { +test('accepts knownWords.decks object format with field arrays', () => { const { context, warnings } = makeContext({ - knownWords: { decks: ['Core Deck', 123] }, + knownWords: { decks: { 'Core Deck': ['Word', 'Reading'], 'Mining': ['Expression'] } }, }); applyAnkiConnectResolution(context); - assert.deepEqual( - context.resolved.ankiConnect.knownWords.decks, - DEFAULT_CONFIG.ankiConnect.knownWords.decks, - ); + assert.deepEqual(context.resolved.ankiConnect.knownWords.decks, { + 'Core Deck': ['Word', 'Reading'], + 'Mining': ['Expression'], + }); + assert.equal(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'), false); +}); + +test('converts legacy knownWords.decks array to object with default fields', () => { + const { context, warnings } = makeContext({ + knownWords: { decks: ['Core Deck'] }, + }); + + applyAnkiConnectResolution(context); + + assert.deepEqual(context.resolved.ankiConnect.knownWords.decks, { + 'Core Deck': ['Expression', 'Word', 'Reading', 'Word Reading'], + }); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks')); }); diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index b2e5042..8d9ce24 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -832,74 +832,70 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { DEFAULT_CONFIG.ankiConnect.knownWords.matchMode; } + const DEFAULT_FIELDS = ['Expression', 'Word', 'Reading', 'Word Reading']; const knownWordsDecks = knownWordsConfig.decks; const legacyNPlusOneDecks = nPlusOneConfig.decks; - if (Array.isArray(knownWordsDecks)) { - const normalizedDecks = knownWordsDecks + if (isObject(knownWordsDecks)) { + const resolved: Record = {}; + for (const [deck, fields] of Object.entries(knownWordsDecks as Record)) { + const deckName = deck.trim(); + if (!deckName) continue; + if (Array.isArray(fields) && fields.every((f) => typeof f === 'string')) { + resolved[deckName] = (fields as string[]).map((f) => f.trim()).filter((f) => f.length > 0); + } else { + context.warn( + `ankiConnect.knownWords.decks["${deckName}"]`, + fields, + DEFAULT_FIELDS, + 'Expected an array of field name strings.', + ); + resolved[deckName] = DEFAULT_FIELDS; + } + } + context.resolved.ankiConnect.knownWords.decks = resolved; + } else if (Array.isArray(knownWordsDecks)) { + const normalized = knownWordsDecks .filter((entry): entry is string => typeof entry === 'string') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); - - if (normalizedDecks.length === knownWordsDecks.length) { - context.resolved.ankiConnect.knownWords.decks = [...new Set(normalizedDecks)]; - } else if (knownWordsDecks.length > 0) { + const resolved: Record = {}; + for (const deck of new Set(normalized)) { + resolved[deck] = DEFAULT_FIELDS; + } + context.resolved.ankiConnect.knownWords.decks = resolved; + if (normalized.length > 0) { context.warn( 'ankiConnect.knownWords.decks', knownWordsDecks, - context.resolved.ankiConnect.knownWords.decks, - 'Expected an array of strings.', + resolved, + 'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }', ); - context.resolved.ankiConnect.knownWords.decks = DEFAULT_CONFIG.ankiConnect.knownWords.decks; - } else { - context.resolved.ankiConnect.knownWords.decks = []; } } else if (knownWordsDecks !== undefined) { context.warn( 'ankiConnect.knownWords.decks', knownWordsDecks, context.resolved.ankiConnect.knownWords.decks, - 'Expected an array of strings.', + 'Expected an object mapping deck names to field arrays.', ); - context.resolved.ankiConnect.knownWords.decks = DEFAULT_CONFIG.ankiConnect.knownWords.decks; } else if (Array.isArray(legacyNPlusOneDecks)) { - const normalizedDecks = legacyNPlusOneDecks + const normalized = legacyNPlusOneDecks .filter((entry): entry is string => typeof entry === 'string') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); - - if (normalizedDecks.length === legacyNPlusOneDecks.length) { - context.resolved.ankiConnect.knownWords.decks = [...new Set(normalizedDecks)]; + const resolved: Record = {}; + 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', - ); - } else if (legacyNPlusOneDecks.length > 0) { - context.warn( - 'ankiConnect.nPlusOne.decks', - legacyNPlusOneDecks, - context.resolved.ankiConnect.knownWords.decks, - 'Expected an array of strings.', - ); - context.resolved.ankiConnect.knownWords.decks = DEFAULT_CONFIG.ankiConnect.knownWords.decks; - } else { - context.resolved.ankiConnect.knownWords.decks = []; - context.warn( - 'ankiConnect.nPlusOne.decks', - legacyNPlusOneDecks, - DEFAULT_CONFIG.ankiConnect.knownWords.decks, - 'Legacy key is deprecated; use ankiConnect.knownWords.decks', + 'Legacy key is deprecated; use ankiConnect.knownWords.decks with object format', ); } - } else if (legacyNPlusOneDecks !== undefined) { - context.warn( - 'ankiConnect.nPlusOne.decks', - legacyNPlusOneDecks, - context.resolved.ankiConnect.knownWords.decks, - 'Expected an array of strings.', - ); - context.resolved.ankiConnect.knownWords.decks = DEFAULT_CONFIG.ankiConnect.knownWords.decks; } const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne); diff --git a/src/types.ts b/src/types.ts index ec94b5d..34c565a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -248,7 +248,7 @@ export interface AnkiConnectConfig { highlightEnabled?: boolean; refreshMinutes?: number; matchMode?: NPlusOneMatchMode; - decks?: string[]; + decks?: Record; color?: string; }; nPlusOne?: { @@ -739,7 +739,7 @@ export interface ResolvedConfig { highlightEnabled: boolean; refreshMinutes: number; matchMode: NPlusOneMatchMode; - decks: string[]; + decks: Record; color: string; }; nPlusOne: {