mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 18:22:42 -08:00
refactor: split startup lifecycle and Anki service architecture
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
Reference in New Issue
Block a user