refactor: split startup lifecycle and Anki service architecture

This commit is contained in:
2026-02-14 22:31:21 -08:00
parent 41f7d754cd
commit 162223943d
30 changed files with 1603 additions and 312 deletions

View File

@@ -69,7 +69,198 @@ test("parses invisible overlay config and new global shortcuts", () => {
test("runtime options registry is centralized", () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, ["anki.autoUpdateNewCards", "anki.kikuFieldGrouping"]);
assert.deepEqual(ids, [
"anki.autoUpdateNewCards",
"anki.nPlusOneMatchMode",
"anki.kikuFieldGrouping",
]);
});
test("validates ankiConnect n+1 behavior values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": "yes",
"refreshMinutes": -5
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(
config.ankiConnect.nPlusOne.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
);
assert.equal(
config.ankiConnect.nPlusOne.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
);
assert.ok(
warnings.some(
(warning) => warning.path === "ankiConnect.nPlusOne.highlightEnabled",
),
);
assert.ok(
warnings.some(
(warning) => warning.path === "ankiConnect.nPlusOne.refreshMinutes",
),
);
});
test("accepts valid ankiConnect n+1 behavior values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
"refreshMinutes": 120
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 120);
});
test("validates ankiConnect n+1 match mode values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"matchMode": "bad-mode"
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(
config.ankiConnect.nPlusOne.matchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
);
assert.ok(
warnings.some((warning) =>
warning.path === "ankiConnect.nPlusOne.matchMode",
),
);
});
test("accepts valid ankiConnect n+1 match mode values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"matchMode": "surface"
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.matchMode, "surface");
});
test("supports legacy ankiConnect.behavior N+1 settings as fallback", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"behavior": {
"nPlusOneHighlightEnabled": true,
"nPlusOneRefreshMinutes": 90,
"nPlusOneMatchMode": "surface"
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 90);
assert.equal(config.ankiConnect.nPlusOne.matchMode, "surface");
assert.ok(
warnings.some(
(warning) =>
warning.path === "ankiConnect.behavior.nPlusOneHighlightEnabled" ||
warning.path === "ankiConnect.behavior.nPlusOneRefreshMinutes" ||
warning.path === "ankiConnect.behavior.nPlusOneMatchMode",
),
);
});
test("accepts valid ankiConnect n+1 deck list", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"decks": ["Deck One", "Deck Two"]
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, ["Deck One", "Deck Two"]);
});
test("falls back to default when ankiConnect n+1 deck list is invalid", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"decks": "not-an-array"
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, []);
assert.ok(
warnings.some((warning) => warning.path === "ankiConnect.nPlusOne.decks"),
);
});
test("template generator includes known keys", () => {

View File

@@ -123,6 +123,12 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
notificationType: "osd",
autoUpdateNewCards: true,
},
nPlusOne: {
highlightEnabled: false,
refreshMinutes: 1440,
matchMode: "headword",
decks: [],
},
metadata: {
pattern: "[SubMiner] %f (%t)",
},
@@ -218,6 +224,23 @@ export const RUNTIME_OPTION_REGISTRY: RuntimeOptionRegistryEntry[] = [
behavior: { autoUpdateNewCards: value === true },
}),
},
{
id: "anki.nPlusOneMatchMode",
path: "ankiConnect.nPlusOne.matchMode",
label: "N+1 Match Mode",
scope: "ankiConnect",
valueType: "enum",
allowedValues: ["headword", "surface"],
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
requiresRestart: false,
formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({
nPlusOne: {
matchMode:
value === "headword" || value === "surface" ? value : "headword",
},
}),
},
{
id: "anki.kikuFieldGrouping",
path: "ankiConnect.isKiku.fieldGrouping",
@@ -272,6 +295,32 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
description: "Automatically update newly added cards.",
runtime: RUNTIME_OPTION_REGISTRY[0],
},
{
path: "ankiConnect.nPlusOne.matchMode",
kind: "enum",
enumValues: ["headword", "surface"],
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
description: "Known-word matching strategy for N+1 highlighting.",
},
{
path: "ankiConnect.nPlusOne.highlightEnabled",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
description: "Enable fast local highlighting for words already known in Anki.",
},
{
path: "ankiConnect.nPlusOne.refreshMinutes",
kind: "number",
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
description: "Minutes between known-word cache refreshes.",
},
{
path: "ankiConnect.nPlusOne.decks",
kind: "array",
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.decks,
description:
"Decks used for N+1 known-word cache scope. Supports one or more deck names.",
},
{
path: "ankiConnect.isKiku.fieldGrouping",
kind: "enum",

View File

@@ -437,6 +437,9 @@ export class ConfigService {
if (isObject(src.ankiConnect)) {
const ac = src.ankiConnect;
const behavior = isObject(ac.behavior)
? (ac.behavior as Record<string, unknown>)
: {};
const aiSource = isObject(ac.ai)
? ac.ai
: isObject(ac.openRouter)
@@ -580,6 +583,159 @@ export class ConfigService {
resolved.ankiConnect.behavior.autoUpdateNewCards = value as boolean;
});
const nPlusOneConfig = isObject(ac.nPlusOne)
? (ac.nPlusOne as Record<string, unknown>)
: {};
const nPlusOneHighlightEnabled = asBoolean(
nPlusOneConfig.highlightEnabled,
);
if (nPlusOneHighlightEnabled !== undefined) {
resolved.ankiConnect.nPlusOne.highlightEnabled =
nPlusOneHighlightEnabled;
} else {
const legacyNPlusOneHighlightEnabled = asBoolean(
behavior.nPlusOneHighlightEnabled,
);
if (legacyNPlusOneHighlightEnabled !== undefined) {
resolved.ankiConnect.nPlusOne.highlightEnabled =
legacyNPlusOneHighlightEnabled;
warn(
"ankiConnect.behavior.nPlusOneHighlightEnabled",
behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
"Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled",
);
} else {
resolved.ankiConnect.nPlusOne.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
}
}
const nPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
const hasValidNPlusOneRefreshMinutes =
nPlusOneRefreshMinutes !== undefined &&
Number.isInteger(nPlusOneRefreshMinutes) &&
nPlusOneRefreshMinutes > 0;
if (nPlusOneRefreshMinutes !== undefined) {
if (hasValidNPlusOneRefreshMinutes) {
resolved.ankiConnect.nPlusOne.refreshMinutes =
nPlusOneRefreshMinutes;
} else {
warn(
"ankiConnect.nPlusOne.refreshMinutes",
nPlusOneConfig.refreshMinutes,
resolved.ankiConnect.nPlusOne.refreshMinutes,
"Expected a positive integer.",
);
resolved.ankiConnect.nPlusOne.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
}
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
const legacyNPlusOneRefreshMinutes = asNumber(
behavior.nPlusOneRefreshMinutes,
);
const hasValidLegacyRefreshMinutes =
legacyNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
legacyNPlusOneRefreshMinutes > 0;
if (hasValidLegacyRefreshMinutes) {
resolved.ankiConnect.nPlusOne.refreshMinutes =
legacyNPlusOneRefreshMinutes;
warn(
"ankiConnect.behavior.nPlusOneRefreshMinutes",
behavior.nPlusOneRefreshMinutes,
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
"Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes",
);
} else {
warn(
"ankiConnect.behavior.nPlusOneRefreshMinutes",
behavior.nPlusOneRefreshMinutes,
resolved.ankiConnect.nPlusOne.refreshMinutes,
"Expected a positive integer.",
);
resolved.ankiConnect.nPlusOne.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
}
} else {
resolved.ankiConnect.nPlusOne.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
}
const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidNPlusOneMatchMode =
nPlusOneMatchMode === "headword" || nPlusOneMatchMode === "surface";
const hasValidLegacyMatchMode =
legacyNPlusOneMatchMode === "headword" ||
legacyNPlusOneMatchMode === "surface";
if (hasValidNPlusOneMatchMode) {
resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode;
} else if (nPlusOneMatchMode !== undefined) {
warn(
"ankiConnect.nPlusOne.matchMode",
nPlusOneConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
"Expected 'headword' or 'surface'.",
);
resolved.ankiConnect.nPlusOne.matchMode =
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
} else if (legacyNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) {
resolved.ankiConnect.nPlusOne.matchMode =
legacyNPlusOneMatchMode;
warn(
"ankiConnect.behavior.nPlusOneMatchMode",
behavior.nPlusOneMatchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
"Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode",
);
} else {
warn(
"ankiConnect.behavior.nPlusOneMatchMode",
behavior.nPlusOneMatchMode,
resolved.ankiConnect.nPlusOne.matchMode,
"Expected 'headword' or 'surface'.",
);
resolved.ankiConnect.nPlusOne.matchMode =
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
}
} else {
resolved.ankiConnect.nPlusOne.matchMode =
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
}
const nPlusOneDecks = nPlusOneConfig.decks;
if (Array.isArray(nPlusOneDecks)) {
const normalizedDecks = nPlusOneDecks
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedDecks.length === nPlusOneDecks.length) {
resolved.ankiConnect.nPlusOne.decks = [
...new Set(normalizedDecks),
];
} else if (nPlusOneDecks.length > 0) {
warn(
"ankiConnect.nPlusOne.decks",
nPlusOneDecks,
resolved.ankiConnect.nPlusOne.decks,
"Expected an array of strings.",
);
} else {
resolved.ankiConnect.nPlusOne.decks = [];
}
} else if (nPlusOneDecks !== undefined) {
warn(
"ankiConnect.nPlusOne.decks",
nPlusOneDecks,
resolved.ankiConnect.nPlusOne.decks,
"Expected an array of strings.",
);
}
if (
resolved.ankiConnect.isKiku.fieldGrouping !== "auto" &&
resolved.ankiConnect.isKiku.fieldGrouping !== "manual" &&