refactor: split known words config from n-plus-one

This commit is contained in:
2026-03-15 22:07:47 -07:00
parent 04682a02cc
commit 92c1557e46
7 changed files with 87 additions and 62 deletions

View File

@@ -217,10 +217,8 @@ export class KnownWordCacheManager {
private getKnownWordDecks(): string[] { private getKnownWordDecks(): string[] {
const configuredDecks = this.deps.getConfig().knownWords?.decks; const configuredDecks = this.deps.getConfig().knownWords?.decks;
if (Array.isArray(configuredDecks)) { if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
return configuredDecks return Object.keys(configuredDecks).map((d) => d.trim()).filter((d) => d.length > 0);
.map((deck) => (typeof deck === 'string' ? deck.trim() : ''))
.filter((deck) => deck.length > 0);
} }
const deck = this.deps.getConfig().deck?.trim(); const deck = this.deps.getConfig().deck?.trim();
@@ -228,6 +226,18 @@ export class KnownWordCacheManager {
} }
private getConfiguredFields(): string[] { private getConfiguredFields(): string[] {
const configuredDecks = this.deps.getConfig().knownWords?.decks;
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
const allFields = new Set<string>();
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']; return ['Expression', 'Word', 'Reading', 'Word Reading'];
} }

View File

@@ -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.highlightEnabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90); assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface'); 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.equal(config.ankiConnect.knownWords.color, '#a6da95');
assert.ok( assert.ok(
warnings.some( 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(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"knownWords": { "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 service = new ConfigService(dir);
const config = service.getConfig(); 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', () => { 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 config = service.getConfig();
const warnings = service.getWarnings(); 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')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'));
}); });

View File

@@ -54,7 +54,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
highlightEnabled: false, highlightEnabled: false,
refreshMinutes: 1440, refreshMinutes: 1440,
matchMode: 'headword', matchMode: 'headword',
decks: [], decks: {},
color: '#a6da95', color: '#a6da95',
}, },
behavior: { behavior: {

View File

@@ -103,9 +103,9 @@ export function buildIntegrationConfigOptionRegistry(
}, },
{ {
path: 'ankiConnect.knownWords.decks', path: 'ankiConnect.knownWords.decks',
kind: 'array', kind: 'object',
defaultValue: defaultConfig.ankiConnect.knownWords.decks, 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', path: 'ankiConnect.nPlusOne.nPlusOne',

View File

@@ -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({ const { context, warnings } = makeContext({
knownWords: { decks: ['Core Deck', 123] }, knownWords: { decks: { 'Core Deck': ['Word', 'Reading'], 'Mining': ['Expression'] } },
}); });
applyAnkiConnectResolution(context); applyAnkiConnectResolution(context);
assert.deepEqual( assert.deepEqual(context.resolved.ankiConnect.knownWords.decks, {
context.resolved.ankiConnect.knownWords.decks, 'Core Deck': ['Word', 'Reading'],
DEFAULT_CONFIG.ankiConnect.knownWords.decks, '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')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'));
}); });

View File

@@ -832,74 +832,70 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode; DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} }
const DEFAULT_FIELDS = ['Expression', 'Word', 'Reading', 'Word Reading'];
const knownWordsDecks = knownWordsConfig.decks; const knownWordsDecks = knownWordsConfig.decks;
const legacyNPlusOneDecks = nPlusOneConfig.decks; const legacyNPlusOneDecks = nPlusOneConfig.decks;
if (Array.isArray(knownWordsDecks)) { if (isObject(knownWordsDecks)) {
const normalizedDecks = knownWordsDecks const resolved: Record<string, string[]> = {};
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
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') .filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim()) .map((entry) => entry.trim())
.filter((entry) => entry.length > 0); .filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
if (normalizedDecks.length === knownWordsDecks.length) { for (const deck of new Set(normalized)) {
context.resolved.ankiConnect.knownWords.decks = [...new Set(normalizedDecks)]; resolved[deck] = DEFAULT_FIELDS;
} else if (knownWordsDecks.length > 0) { }
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn( context.warn(
'ankiConnect.knownWords.decks', 'ankiConnect.knownWords.decks',
knownWordsDecks, knownWordsDecks,
context.resolved.ankiConnect.knownWords.decks, resolved,
'Expected an array of strings.', '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) { } else if (knownWordsDecks !== undefined) {
context.warn( context.warn(
'ankiConnect.knownWords.decks', 'ankiConnect.knownWords.decks',
knownWordsDecks, knownWordsDecks,
context.resolved.ankiConnect.knownWords.decks, 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)) { } else if (Array.isArray(legacyNPlusOneDecks)) {
const normalizedDecks = legacyNPlusOneDecks const normalized = legacyNPlusOneDecks
.filter((entry): entry is string => typeof entry === 'string') .filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim()) .map((entry) => entry.trim())
.filter((entry) => entry.length > 0); .filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
if (normalizedDecks.length === legacyNPlusOneDecks.length) { for (const deck of new Set(normalized)) {
context.resolved.ankiConnect.knownWords.decks = [...new Set(normalizedDecks)]; resolved[deck] = DEFAULT_FIELDS;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn( context.warn(
'ankiConnect.nPlusOne.decks', 'ankiConnect.nPlusOne.decks',
legacyNPlusOneDecks, legacyNPlusOneDecks,
DEFAULT_CONFIG.ankiConnect.knownWords.decks, 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.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',
); );
} }
} 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); const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);

View File

@@ -248,7 +248,7 @@ export interface AnkiConnectConfig {
highlightEnabled?: boolean; highlightEnabled?: boolean;
refreshMinutes?: number; refreshMinutes?: number;
matchMode?: NPlusOneMatchMode; matchMode?: NPlusOneMatchMode;
decks?: string[]; decks?: Record<string, string[]>;
color?: string; color?: string;
}; };
nPlusOne?: { nPlusOne?: {
@@ -739,7 +739,7 @@ export interface ResolvedConfig {
highlightEnabled: boolean; highlightEnabled: boolean;
refreshMinutes: number; refreshMinutes: number;
matchMode: NPlusOneMatchMode; matchMode: NPlusOneMatchMode;
decks: string[]; decks: Record<string, string[]>;
color: string; color: string;
}; };
nPlusOne: { nPlusOne: {