From deae61b21159de8e1506760e08fe51e7209ba682 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 17 Jul 2026 23:05:59 -0700 Subject: [PATCH] refactor: split anki-connect and stats-server resolvers into modules (#169) --- changes/split-resolvers.md | 5 + docs/architecture/domains.md | 10 +- src/config/anki-connect-nplusone-migration.ts | 16 +- src/config/config.test.ts | 31 + src/config/resolve/anki-connect.test.ts | 119 ++ src/config/resolve/anki-connect.ts | 959 +---------- src/config/resolve/anki-connect/ai.ts | 57 + src/config/resolve/anki-connect/initialize.ts | 81 + src/config/resolve/anki-connect/kiku.ts | 19 + .../resolve/anki-connect/known-words.ts | 267 +++ src/config/resolve/anki-connect/lapis.ts | 58 + src/config/resolve/anki-connect/legacy.ts | 364 +++++ .../resolve/anki-connect/modern-behavior.ts | 55 + .../resolve/anki-connect/modern-fields.ts | 24 + .../resolve/anki-connect/modern-media.ts | 145 ++ .../resolve/anki-connect/modern-metadata.ts | 22 + .../resolve/anki-connect/modern-value.ts | 46 + src/config/resolve/anki-connect/modern.ts | 29 + src/config/resolve/anki-connect/proxy.ts | 70 + src/config/resolve/anki-connect/shared.ts | 9 + src/config/resolve/anki-connect/tags.ts | 33 + .../stats-server-mining-support.test.ts | 31 + .../stats-server-route-groups.test.ts | 23 + .../services/__tests__/stats-server.test.ts | 173 +- src/core/services/stats-server.ts | 1456 +---------------- .../services/stats-server/analytics-routes.ts | 151 ++ .../stats-server/integration-routes.ts | 224 +++ .../services/stats-server/library-routes.ts | 193 +++ .../services/stats-server/mining-routes.ts | 469 ++++++ .../services/stats-server/mining-support.ts | 195 +++ .../services/stats-server/route-support.ts | 258 +++ src/core/services/stats-server/routes.ts | 5 + .../services/stats-server/static-routes.ts | 26 + src/stats-transport-architecture.test.ts | 14 +- 34 files changed, 3239 insertions(+), 2398 deletions(-) create mode 100644 changes/split-resolvers.md create mode 100644 src/config/resolve/anki-connect/ai.ts create mode 100644 src/config/resolve/anki-connect/initialize.ts create mode 100644 src/config/resolve/anki-connect/kiku.ts create mode 100644 src/config/resolve/anki-connect/known-words.ts create mode 100644 src/config/resolve/anki-connect/lapis.ts create mode 100644 src/config/resolve/anki-connect/legacy.ts create mode 100644 src/config/resolve/anki-connect/modern-behavior.ts create mode 100644 src/config/resolve/anki-connect/modern-fields.ts create mode 100644 src/config/resolve/anki-connect/modern-media.ts create mode 100644 src/config/resolve/anki-connect/modern-metadata.ts create mode 100644 src/config/resolve/anki-connect/modern-value.ts create mode 100644 src/config/resolve/anki-connect/modern.ts create mode 100644 src/config/resolve/anki-connect/proxy.ts create mode 100644 src/config/resolve/anki-connect/shared.ts create mode 100644 src/config/resolve/anki-connect/tags.ts create mode 100644 src/core/services/__tests__/stats-server-mining-support.test.ts create mode 100644 src/core/services/__tests__/stats-server-route-groups.test.ts create mode 100644 src/core/services/stats-server/analytics-routes.ts create mode 100644 src/core/services/stats-server/integration-routes.ts create mode 100644 src/core/services/stats-server/library-routes.ts create mode 100644 src/core/services/stats-server/mining-routes.ts create mode 100644 src/core/services/stats-server/mining-support.ts create mode 100644 src/core/services/stats-server/route-support.ts create mode 100644 src/core/services/stats-server/routes.ts create mode 100644 src/core/services/stats-server/static-routes.ts diff --git a/changes/split-resolvers.md b/changes/split-resolvers.md new file mode 100644 index 00000000..216ad7a1 --- /dev/null +++ b/changes/split-resolvers.md @@ -0,0 +1,5 @@ +type: fixed +area: stats + +- Validated nested and legacy AnkiConnect settings after splitting the resolver, preserving valid modern overrides while warning and falling back for invalid primitive values. +- Hardened stats routes against malformed IDs and static paths, stalled AniList searches, word-mining media collisions, missing Yomitan bridges, and throwing timing observers. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 3a5e64de..789a4142 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -3,7 +3,7 @@ # Domain Ownership Status: active -Last verified: 2026-05-23 +Last verified: 2026-07-15 Owner: Kyle Yasuda Read when: you need to find the owner module for a behavior or test surface @@ -16,7 +16,9 @@ Read when: you need to find the owner module for a behavior or test surface ## Product / Integration Domains -- Config system: `src/config/` +- Config system: `src/config/`; Anki resolution is composed by + `src/config/resolve/anki-connect.ts` from focused resolvers in + `src/config/resolve/anki-connect/` - Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts` - MPV runtime and protocol: `src/core/services/mpv*.ts` - Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/` @@ -26,7 +28,9 @@ Read when: you need to find the owner module for a behavior or test surface - AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/` - Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*` - Window trackers: `src/window-trackers/` -- Stats app: `stats/` +- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support + in `src/core/services/stats-server/` +- Stats SPA: `stats/` - Public docs site: `docs-site/` ## Shared Contract Entry Points diff --git a/src/config/anki-connect-nplusone-migration.ts b/src/config/anki-connect-nplusone-migration.ts index 5c0b8d76..a0317869 100644 --- a/src/config/anki-connect-nplusone-migration.ts +++ b/src/config/anki-connect-nplusone-migration.ts @@ -112,8 +112,20 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): { if (!key) continue; const valueNode = propertyValue(property); const value = valueNode ? getNodeValue(valueNode) : undefined; - if (key === 'enabled' || key === 'minSentenceWords') { - canonicalNPlusOneValues.set(key, value); + if (key === 'enabled') { + if (typeof value === 'boolean') { + canonicalNPlusOneValues.set(key, value); + } else { + canonicalNPlusOneValues.delete(key); + } + continue; + } + if (key === 'minSentenceWords') { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + canonicalNPlusOneValues.set(key, value); + } else { + canonicalNPlusOneValues.delete(key); + } continue; } if (key in LEGACY_N_PLUS_ONE_PATH_MAP) { diff --git a/src/config/config.test.ts b/src/config/config.test.ts index badc8e92..864dcc8b 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2462,6 +2462,37 @@ test('resolves duplicate ankiConnect nPlusOne objects without rewriting config', assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent); }); +test('later invalid duplicate nPlusOne values supersede earlier valid values', () => { + const dir = makeTempDir(); + const configPath = path.join(dir, 'config.jsonc'); + const originalContent = `{ + "ankiConnect": { + "nPlusOne": { + "enabled": true, + "minSentenceWords": 4 + }, + "nPlusOne": { + "enabled": "yes", + "minSentenceWords": "4" + } + } + }`; + fs.writeFileSync(configPath, originalContent, 'utf-8'); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled); + assert.equal( + config.ankiConnect.nPlusOne.minSentenceWords, + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.enabled')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords')); + assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent); +}); + test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts index b0e7262f..7990936a 100644 --- a/src/config/resolve/anki-connect.test.ts +++ b/src/config/resolve/anki-connect.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions'; import { createWarningCollector } from '../warnings'; import { applyAnkiConnectResolution } from './anki-connect'; +import { applyAnkiKnownWordsResolution } from './anki-connect/known-words'; import type { ResolveContext } from './context'; function makeContext(ankiConnect: unknown): { @@ -39,6 +40,90 @@ test('modern invalid knownWords.highlightEnabled warns modern key and does not f ); }); +test('invalid modern known-words primitive values warn and keep defaults', () => { + const { context, warnings } = makeContext({ + knownWords: { + refreshMinutes: 'daily', + matchMode: false, + }, + nPlusOne: { + minSentenceWords: 'three', + }, + }); + + applyAnkiConnectResolution(context); + + assert.equal( + context.resolved.ankiConnect.knownWords.refreshMinutes, + DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes, + ); + assert.equal( + context.resolved.ankiConnect.knownWords.matchMode, + DEFAULT_CONFIG.ankiConnect.knownWords.matchMode, + ); + assert.equal( + context.resolved.ankiConnect.nPlusOne.minSentenceWords, + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords, + ); + assert.deepEqual( + warnings.map((warning) => warning.path), + [ + 'ankiConnect.knownWords.refreshMinutes', + 'ankiConnect.nPlusOne.minSentenceWords', + 'ankiConnect.knownWords.matchMode', + ], + ); +}); + +test('invalid legacy known-words primitive values warn and keep defaults', () => { + const { context, warnings } = makeContext({ + behavior: { + nPlusOneHighlightEnabled: 'yes', + nPlusOneRefreshMinutes: 'daily', + nPlusOneMatchMode: false, + }, + }); + + applyAnkiConnectResolution(context); + + assert.equal( + context.resolved.ankiConnect.knownWords.highlightEnabled, + DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled, + ); + assert.equal( + context.resolved.ankiConnect.knownWords.refreshMinutes, + DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes, + ); + assert.equal( + context.resolved.ankiConnect.knownWords.matchMode, + DEFAULT_CONFIG.ankiConnect.knownWords.matchMode, + ); + assert.deepEqual( + warnings.map((warning) => warning.path), + [ + 'ankiConnect.behavior.nPlusOneHighlightEnabled', + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + 'ankiConnect.behavior.nPlusOneMatchMode', + ], + ); +}); + +test('known-words resolution can run independently from other Anki domains', () => { + const { context, warnings } = makeContext({ + knownWords: { highlightEnabled: true }, + proxy: { port: -1 }, + }); + const ankiConnect = context.src.ankiConnect as Record; + + applyAnkiKnownWordsResolution(context, ankiConnect, {}); + + assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true); + assert.equal( + warnings.some((warning) => warning.path.startsWith('ankiConnect.proxy')), + false, + ); +}); + test('normalizes ankiConnect tags by trimming and deduping', () => { const { context, warnings } = makeContext({ tags: [' SubMiner ', 'Mining', 'SubMiner', ' Mining '], @@ -177,6 +262,40 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => { ); }); +test('invalid modern Anki subtrees warn and keep resolved defaults', () => { + const { context, warnings } = makeContext({ + fields: { word: 7 }, + media: { generateAudio: 'yes' }, + behavior: { overwriteAudio: 'yes' }, + metadata: { pattern: false }, + }); + + applyAnkiConnectResolution(context); + + assert.equal(context.resolved.ankiConnect.fields.word, DEFAULT_CONFIG.ankiConnect.fields.word); + assert.equal( + context.resolved.ankiConnect.media.generateAudio, + DEFAULT_CONFIG.ankiConnect.media.generateAudio, + ); + assert.equal( + context.resolved.ankiConnect.behavior.overwriteAudio, + DEFAULT_CONFIG.ankiConnect.behavior.overwriteAudio, + ); + assert.equal( + context.resolved.ankiConnect.metadata.pattern, + DEFAULT_CONFIG.ankiConnect.metadata.pattern, + ); + assert.deepEqual( + warnings.map((warning) => warning.path), + [ + 'ankiConnect.fields.word', + 'ankiConnect.media.generateAudio', + 'ankiConnect.behavior.overwriteAudio', + 'ankiConnect.metadata.pattern', + ], + ); +}); + test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => { const { context, warnings } = makeContext({ wordField: 'TargetWordLegacy', diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index a668e4f5..0f7c151e 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -1,952 +1,25 @@ -import { DEFAULT_CONFIG } from '../definitions'; import type { ResolveContext } from './context'; -import { isNotificationType, type NotificationType } from '../../types/notification'; -import { asBoolean, asColor, asNumber, asString, isObject } from './shared'; - -function asNotificationType(value: unknown): NotificationType | undefined { - return isNotificationType(value) ? value : undefined; -} +import { initializeAnkiConnectResolution } from './anki-connect/initialize'; +import { applyAnkiKikuResolution } from './anki-connect/kiku'; +import { applyAnkiKnownWordsResolution } from './anki-connect/known-words'; +import { applyAnkiLegacyResolution } from './anki-connect/legacy'; +import { applyAnkiModernResolution } from './anki-connect/modern'; +import { isObject } from './shared'; export function applyAnkiConnectResolution(context: ResolveContext): void { if (!isObject(context.src.ankiConnect)) { return; } - const ac = context.src.ankiConnect; - const behavior = isObject(ac.behavior) ? (ac.behavior as Record) : {}; - const fields = isObject(ac.fields) ? (ac.fields as Record) : {}; - const media = isObject(ac.media) ? (ac.media as Record) : {}; - const metadata = isObject(ac.metadata) ? (ac.metadata as Record) : {}; - const proxy = isObject(ac.proxy) ? (ac.proxy as Record) : {}; - const legacyKeys = new Set([ - 'wordField', - 'audioField', - 'imageField', - 'sentenceField', - 'miscInfoField', - 'miscInfoPattern', - 'generateAudio', - 'generateImage', - 'imageType', - 'imageFormat', - 'imageQuality', - 'imageMaxWidth', - 'imageMaxHeight', - 'animatedFps', - 'animatedMaxWidth', - 'animatedMaxHeight', - 'animatedCrf', - 'syncAnimatedImageToWordAudio', - 'audioPadding', - 'fallbackDuration', - 'maxMediaDuration', - 'overwriteAudio', - 'overwriteImage', - 'mediaInsertMode', - 'highlightWord', - 'notificationType', - 'autoUpdateNewCards', - ]); - const hasOwn = (obj: Record, key: string): boolean => - Object.prototype.hasOwnProperty.call(obj, key); + const ankiConnect = context.src.ankiConnect; + const behavior = isObject(ankiConnect.behavior) ? ankiConnect.behavior : {}; + const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {}; + const media = isObject(ankiConnect.media) ? ankiConnect.media : {}; + const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {}; - const { - knownWords: _knownWordsConfigFromAnkiConnect, - nPlusOne: _nPlusOneConfigFromAnkiConnect, - ai: _ankiAiConfig, - ...ankiConnectWithoutKnownWordsOrNPlusOne - } = ac as Record; - const ankiConnectWithoutLegacy = Object.fromEntries( - Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !legacyKeys.has(key)), - ); - - context.resolved.ankiConnect = { - ...context.resolved.ankiConnect, - ...(isObject(ankiConnectWithoutLegacy) - ? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>) - : {}), - fields: { - ...context.resolved.ankiConnect.fields, - ...(isObject(ac.fields) - ? (ac.fields as (typeof context.resolved)['ankiConnect']['fields']) - : {}), - }, - media: { - ...context.resolved.ankiConnect.media, - ...(isObject(ac.media) - ? (ac.media as (typeof context.resolved)['ankiConnect']['media']) - : {}), - }, - knownWords: { - ...context.resolved.ankiConnect.knownWords, - }, - behavior: { - ...context.resolved.ankiConnect.behavior, - ...(isObject(ac.behavior) - ? (ac.behavior as (typeof context.resolved)['ankiConnect']['behavior']) - : {}), - }, - proxy: { - ...context.resolved.ankiConnect.proxy, - }, - metadata: { - ...context.resolved.ankiConnect.metadata, - ...(isObject(ac.metadata) - ? (ac.metadata as (typeof context.resolved)['ankiConnect']['metadata']) - : {}), - }, - isLapis: { - ...context.resolved.ankiConnect.isLapis, - }, - isKiku: { - ...context.resolved.ankiConnect.isKiku, - ...(isObject(ac.isKiku) - ? (ac.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) - : {}), - }, - }; - - if (hasOwn(media, 'mirrorMpvVolume')) { - const parsed = asBoolean(media.mirrorMpvVolume); - if (parsed === undefined) { - context.resolved.ankiConnect.media.mirrorMpvVolume = - DEFAULT_CONFIG.ankiConnect.media.mirrorMpvVolume; - context.warn( - 'ankiConnect.media.mirrorMpvVolume', - media.mirrorMpvVolume, - context.resolved.ankiConnect.media.mirrorMpvVolume, - 'Expected boolean.', - ); - } else { - context.resolved.ankiConnect.media.mirrorMpvVolume = parsed; - } - } - - if (hasOwn(behavior, 'notificationType')) { - const parsed = asNotificationType(behavior.notificationType); - if (parsed === undefined) { - context.resolved.ankiConnect.behavior.notificationType = - DEFAULT_CONFIG.ankiConnect.behavior.notificationType; - context.warn( - 'ankiConnect.behavior.notificationType', - behavior.notificationType, - context.resolved.ankiConnect.behavior.notificationType, - "Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.", - ); - } else { - context.resolved.ankiConnect.behavior.notificationType = parsed; - } - } - - if (isObject(ac.isLapis)) { - const lapisEnabled = asBoolean(ac.isLapis.enabled); - if (lapisEnabled !== undefined) { - context.resolved.ankiConnect.isLapis.enabled = lapisEnabled; - } else if (ac.isLapis.enabled !== undefined) { - context.warn( - 'ankiConnect.isLapis.enabled', - ac.isLapis.enabled, - context.resolved.ankiConnect.isLapis.enabled, - 'Expected boolean.', - ); - } - - const sentenceCardModel = asString(ac.isLapis.sentenceCardModel); - if (sentenceCardModel !== undefined) { - context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel; - } else if (ac.isLapis.sentenceCardModel !== undefined) { - context.warn( - 'ankiConnect.isLapis.sentenceCardModel', - ac.isLapis.sentenceCardModel, - context.resolved.ankiConnect.isLapis.sentenceCardModel, - 'Expected string.', - ); - } - - if (ac.isLapis.sentenceCardSentenceField !== undefined) { - context.warn( - 'ankiConnect.isLapis.sentenceCardSentenceField', - ac.isLapis.sentenceCardSentenceField, - 'Sentence', - 'Deprecated key; sentence-card sentence field is fixed to Sentence.', - ); - } - - if (ac.isLapis.sentenceCardAudioField !== undefined) { - context.warn( - 'ankiConnect.isLapis.sentenceCardAudioField', - ac.isLapis.sentenceCardAudioField, - 'SentenceAudio', - 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.', - ); - } - } else if (ac.isLapis !== undefined) { - context.warn( - 'ankiConnect.isLapis', - ac.isLapis, - context.resolved.ankiConnect.isLapis, - 'Expected object.', - ); - } - - if (isObject(ac.proxy)) { - const proxyEnabled = asBoolean(proxy.enabled); - if (proxyEnabled !== undefined) { - context.resolved.ankiConnect.proxy.enabled = proxyEnabled; - } else if (proxy.enabled !== undefined) { - context.warn( - 'ankiConnect.proxy.enabled', - proxy.enabled, - context.resolved.ankiConnect.proxy.enabled, - 'Expected boolean.', - ); - } - - const proxyHost = asString(proxy.host); - if (proxyHost !== undefined && proxyHost.trim().length > 0) { - context.resolved.ankiConnect.proxy.host = proxyHost.trim(); - } else if (proxy.host !== undefined) { - context.warn( - 'ankiConnect.proxy.host', - proxy.host, - context.resolved.ankiConnect.proxy.host, - 'Expected non-empty string.', - ); - } - - const proxyUpstreamUrl = asString(proxy.upstreamUrl); - if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) { - context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim(); - } else if (proxy.upstreamUrl !== undefined) { - context.warn( - 'ankiConnect.proxy.upstreamUrl', - proxy.upstreamUrl, - context.resolved.ankiConnect.proxy.upstreamUrl, - 'Expected non-empty string.', - ); - } - - const proxyPort = asNumber(proxy.port); - if ( - proxyPort !== undefined && - Number.isInteger(proxyPort) && - proxyPort >= 1 && - proxyPort <= 65535 - ) { - context.resolved.ankiConnect.proxy.port = proxyPort; - } else if (proxy.port !== undefined) { - context.warn( - 'ankiConnect.proxy.port', - proxy.port, - context.resolved.ankiConnect.proxy.port, - 'Expected integer between 1 and 65535.', - ); - } - } else if (ac.proxy !== undefined) { - context.warn( - 'ankiConnect.proxy', - ac.proxy, - context.resolved.ankiConnect.proxy, - 'Expected object.', - ); - } - - if (isObject(ac.ai)) { - const aiEnabled = asBoolean(ac.ai.enabled); - if (aiEnabled !== undefined) { - context.resolved.ankiConnect.ai.enabled = aiEnabled; - } else if (ac.ai.enabled !== undefined) { - context.warn( - 'ankiConnect.ai.enabled', - ac.ai.enabled, - context.resolved.ankiConnect.ai.enabled, - 'Expected boolean.', - ); - } - - const aiModel = asString(ac.ai.model); - if (aiModel !== undefined) { - context.resolved.ankiConnect.ai.model = aiModel; - } else if (ac.ai.model !== undefined) { - context.warn( - 'ankiConnect.ai.model', - ac.ai.model, - context.resolved.ankiConnect.ai.model, - 'Expected string.', - ); - } - - const aiSystemPrompt = asString(ac.ai.systemPrompt); - if (aiSystemPrompt !== undefined) { - context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt; - } else if (ac.ai.systemPrompt !== undefined) { - context.warn( - 'ankiConnect.ai.systemPrompt', - ac.ai.systemPrompt, - context.resolved.ankiConnect.ai.systemPrompt, - 'Expected string.', - ); - } - } else { - const aiEnabled = asBoolean(ac.ai); - if (aiEnabled !== undefined) { - context.resolved.ankiConnect.ai.enabled = aiEnabled; - } else if (ac.ai !== undefined) { - context.warn( - 'ankiConnect.ai', - ac.ai, - context.resolved.ankiConnect.ai.enabled, - 'Expected boolean or object.', - ); - } - } - - if (Array.isArray(ac.tags)) { - const normalizedTags = ac.tags - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - if (normalizedTags.length === ac.tags.length) { - context.resolved.ankiConnect.tags = [...new Set(normalizedTags)]; - } else { - context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; - context.warn( - 'ankiConnect.tags', - ac.tags, - context.resolved.ankiConnect.tags, - 'Expected an array of non-empty strings.', - ); - } - } else if (ac.tags !== undefined) { - context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; - context.warn( - 'ankiConnect.tags', - ac.tags, - context.resolved.ankiConnect.tags, - 'Expected an array of strings.', - ); - } - - const legacy = ac as Record; - const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => { - const parsed = asNumber(value); - if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) { - return undefined; - } - return parsed; - }; - const asPositiveInteger = (value: unknown): number | undefined => { - const parsed = asNumber(value); - if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) { - return undefined; - } - return parsed; - }; - const asPositiveNumber = (value: unknown): number | undefined => { - const parsed = asNumber(value); - if (parsed === undefined || parsed <= 0) { - return undefined; - } - return parsed; - }; - const asNonNegativeNumber = (value: unknown): number | undefined => { - const parsed = asNumber(value); - if (parsed === undefined || parsed < 0) { - return undefined; - } - return parsed; - }; - const asImageType = (value: unknown): 'static' | 'avif' | undefined => { - return value === 'static' || value === 'avif' ? value : undefined; - }; - const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => { - return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined; - }; - const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => { - return value === 'append' || value === 'prepend' ? value : undefined; - }; - const mapLegacy = ( - key: string, - parse: (value: unknown) => T | undefined, - apply: (value: T) => void, - fallback: unknown, - message: string, - ): void => { - const value = legacy[key]; - if (value === undefined) return; - const parsed = parse(value); - if (parsed === undefined) { - context.warn(`ankiConnect.${key}`, value, fallback, message); - return; - } - apply(parsed); - }; - - if (!hasOwn(fields, 'audio')) { - mapLegacy( - 'audioField', - asString, - (value) => { - context.resolved.ankiConnect.fields.audio = value; - }, - context.resolved.ankiConnect.fields.audio, - 'Expected string.', - ); - } - if (!hasOwn(fields, 'word')) { - mapLegacy( - 'wordField', - asString, - (value) => { - context.resolved.ankiConnect.fields.word = value; - }, - context.resolved.ankiConnect.fields.word, - 'Expected string.', - ); - } - if (!hasOwn(fields, 'image')) { - mapLegacy( - 'imageField', - asString, - (value) => { - context.resolved.ankiConnect.fields.image = value; - }, - context.resolved.ankiConnect.fields.image, - 'Expected string.', - ); - } - if (!hasOwn(fields, 'sentence')) { - mapLegacy( - 'sentenceField', - asString, - (value) => { - context.resolved.ankiConnect.fields.sentence = value; - }, - context.resolved.ankiConnect.fields.sentence, - 'Expected string.', - ); - } - if (!hasOwn(fields, 'miscInfo')) { - mapLegacy( - 'miscInfoField', - asString, - (value) => { - context.resolved.ankiConnect.fields.miscInfo = value; - }, - context.resolved.ankiConnect.fields.miscInfo, - 'Expected string.', - ); - } - if (!hasOwn(metadata, 'pattern')) { - mapLegacy( - 'miscInfoPattern', - asString, - (value) => { - context.resolved.ankiConnect.metadata.pattern = value; - }, - context.resolved.ankiConnect.metadata.pattern, - 'Expected string.', - ); - } - if (!hasOwn(media, 'generateAudio')) { - mapLegacy( - 'generateAudio', - asBoolean, - (value) => { - context.resolved.ankiConnect.media.generateAudio = value; - }, - context.resolved.ankiConnect.media.generateAudio, - 'Expected boolean.', - ); - } - if (!hasOwn(media, 'generateImage')) { - mapLegacy( - 'generateImage', - asBoolean, - (value) => { - context.resolved.ankiConnect.media.generateImage = value; - }, - context.resolved.ankiConnect.media.generateImage, - 'Expected boolean.', - ); - } - if (!hasOwn(media, 'imageType')) { - mapLegacy( - 'imageType', - asImageType, - (value) => { - context.resolved.ankiConnect.media.imageType = value; - }, - context.resolved.ankiConnect.media.imageType, - "Expected 'static' or 'avif'.", - ); - } - if (!hasOwn(media, 'imageFormat')) { - mapLegacy( - 'imageFormat', - asImageFormat, - (value) => { - context.resolved.ankiConnect.media.imageFormat = value; - }, - context.resolved.ankiConnect.media.imageFormat, - "Expected 'jpg', 'png', or 'webp'.", - ); - } - if (!hasOwn(media, 'imageQuality')) { - mapLegacy( - 'imageQuality', - (value) => asIntegerInRange(value, 1, 100), - (value) => { - context.resolved.ankiConnect.media.imageQuality = value; - }, - context.resolved.ankiConnect.media.imageQuality, - 'Expected integer between 1 and 100.', - ); - } - if (!hasOwn(media, 'imageMaxWidth')) { - mapLegacy( - 'imageMaxWidth', - asPositiveInteger, - (value) => { - context.resolved.ankiConnect.media.imageMaxWidth = value; - }, - context.resolved.ankiConnect.media.imageMaxWidth, - 'Expected positive integer.', - ); - } - if (!hasOwn(media, 'imageMaxHeight')) { - mapLegacy( - 'imageMaxHeight', - asPositiveInteger, - (value) => { - context.resolved.ankiConnect.media.imageMaxHeight = value; - }, - context.resolved.ankiConnect.media.imageMaxHeight, - 'Expected positive integer.', - ); - } - if (!hasOwn(media, 'animatedFps')) { - mapLegacy( - 'animatedFps', - (value) => asIntegerInRange(value, 1, 60), - (value) => { - context.resolved.ankiConnect.media.animatedFps = value; - }, - context.resolved.ankiConnect.media.animatedFps, - 'Expected integer between 1 and 60.', - ); - } - if (!hasOwn(media, 'animatedMaxWidth')) { - mapLegacy( - 'animatedMaxWidth', - asPositiveInteger, - (value) => { - context.resolved.ankiConnect.media.animatedMaxWidth = value; - }, - context.resolved.ankiConnect.media.animatedMaxWidth, - 'Expected positive integer.', - ); - } - if (!hasOwn(media, 'animatedMaxHeight')) { - mapLegacy( - 'animatedMaxHeight', - asPositiveInteger, - (value) => { - context.resolved.ankiConnect.media.animatedMaxHeight = value; - }, - context.resolved.ankiConnect.media.animatedMaxHeight, - 'Expected positive integer.', - ); - } - if (!hasOwn(media, 'animatedCrf')) { - mapLegacy( - 'animatedCrf', - (value) => asIntegerInRange(value, 0, 63), - (value) => { - context.resolved.ankiConnect.media.animatedCrf = value; - }, - context.resolved.ankiConnect.media.animatedCrf, - 'Expected integer between 0 and 63.', - ); - } - if (!hasOwn(media, 'syncAnimatedImageToWordAudio')) { - mapLegacy( - 'syncAnimatedImageToWordAudio', - asBoolean, - (value) => { - context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio = value; - }, - context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio, - 'Expected boolean.', - ); - } - if (!hasOwn(media, 'audioPadding')) { - mapLegacy( - 'audioPadding', - asNonNegativeNumber, - (value) => { - context.resolved.ankiConnect.media.audioPadding = value; - }, - context.resolved.ankiConnect.media.audioPadding, - 'Expected non-negative number.', - ); - } - if (!hasOwn(media, 'fallbackDuration')) { - mapLegacy( - 'fallbackDuration', - asPositiveNumber, - (value) => { - context.resolved.ankiConnect.media.fallbackDuration = value; - }, - context.resolved.ankiConnect.media.fallbackDuration, - 'Expected positive number.', - ); - } - if (!hasOwn(media, 'maxMediaDuration')) { - mapLegacy( - 'maxMediaDuration', - asNonNegativeNumber, - (value) => { - context.resolved.ankiConnect.media.maxMediaDuration = value; - }, - context.resolved.ankiConnect.media.maxMediaDuration, - 'Expected non-negative number.', - ); - } - if (!hasOwn(behavior, 'overwriteAudio')) { - mapLegacy( - 'overwriteAudio', - asBoolean, - (value) => { - context.resolved.ankiConnect.behavior.overwriteAudio = value; - }, - context.resolved.ankiConnect.behavior.overwriteAudio, - 'Expected boolean.', - ); - } - if (!hasOwn(behavior, 'overwriteImage')) { - mapLegacy( - 'overwriteImage', - asBoolean, - (value) => { - context.resolved.ankiConnect.behavior.overwriteImage = value; - }, - context.resolved.ankiConnect.behavior.overwriteImage, - 'Expected boolean.', - ); - } - if (!hasOwn(behavior, 'mediaInsertMode')) { - mapLegacy( - 'mediaInsertMode', - asMediaInsertMode, - (value) => { - context.resolved.ankiConnect.behavior.mediaInsertMode = value; - }, - context.resolved.ankiConnect.behavior.mediaInsertMode, - "Expected 'append' or 'prepend'.", - ); - } - if (!hasOwn(behavior, 'highlightWord')) { - mapLegacy( - 'highlightWord', - asBoolean, - (value) => { - context.resolved.ankiConnect.behavior.highlightWord = value; - }, - context.resolved.ankiConnect.behavior.highlightWord, - 'Expected boolean.', - ); - } - if (!hasOwn(behavior, 'notificationType')) { - mapLegacy( - 'notificationType', - asNotificationType, - (value) => { - context.resolved.ankiConnect.behavior.notificationType = value; - }, - context.resolved.ankiConnect.behavior.notificationType, - "Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.", - ); - } - if (!hasOwn(behavior, 'autoUpdateNewCards')) { - mapLegacy( - 'autoUpdateNewCards', - asBoolean, - (value) => { - context.resolved.ankiConnect.behavior.autoUpdateNewCards = value; - }, - context.resolved.ankiConnect.behavior.autoUpdateNewCards, - 'Expected boolean.', - ); - } - - const knownWordsConfig = isObject(ac.knownWords) - ? (ac.knownWords as Record) - : {}; - const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record) : {}; - - const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled); - if (knownWordsHighlightEnabled !== undefined) { - context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled; - } else if (knownWordsConfig.highlightEnabled !== undefined) { - context.warn( - 'ankiConnect.knownWords.highlightEnabled', - knownWordsConfig.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) { - context.resolved.ankiConnect.knownWords.highlightEnabled = - legacyBehaviorNPlusOneHighlightEnabled; - context.warn( - 'ankiConnect.behavior.nPlusOneHighlightEnabled', - behavior.nPlusOneHighlightEnabled, - DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled, - 'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled', - ); - } else { - context.resolved.ankiConnect.knownWords.highlightEnabled = - DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled; - } - } - - const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes); - const hasValidKnownWordsRefreshMinutes = - knownWordsRefreshMinutes !== undefined && - Number.isInteger(knownWordsRefreshMinutes) && - knownWordsRefreshMinutes > 0; - if (knownWordsRefreshMinutes !== undefined) { - if (hasValidKnownWordsRefreshMinutes) { - context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes; - } else { - context.warn( - 'ankiConnect.knownWords.refreshMinutes', - knownWordsConfig.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 = - legacyBehaviorNPlusOneRefreshMinutes !== undefined && - Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) && - legacyBehaviorNPlusOneRefreshMinutes > 0; - if (hasValidLegacyRefreshMinutes) { - context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes; - context.warn( - 'ankiConnect.behavior.nPlusOneRefreshMinutes', - behavior.nPlusOneRefreshMinutes, - DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes, - 'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes', - ); - } else { - context.warn( - 'ankiConnect.behavior.nPlusOneRefreshMinutes', - behavior.nPlusOneRefreshMinutes, - context.resolved.ankiConnect.knownWords.refreshMinutes, - 'Expected a positive integer.', - ); - context.resolved.ankiConnect.knownWords.refreshMinutes = - DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes; - } - } else { - context.resolved.ankiConnect.knownWords.refreshMinutes = - DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes; - } - - const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately); - if (knownWordsAddMinedWordsImmediately !== undefined) { - context.resolved.ankiConnect.knownWords.addMinedWordsImmediately = - knownWordsAddMinedWordsImmediately; - } else if (knownWordsConfig.addMinedWordsImmediately !== undefined) { - context.warn( - 'ankiConnect.knownWords.addMinedWordsImmediately', - knownWordsConfig.addMinedWordsImmediately, - context.resolved.ankiConnect.knownWords.addMinedWordsImmediately, - 'Expected boolean.', - ); - context.resolved.ankiConnect.knownWords.addMinedWordsImmediately = - DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately; - } else { - context.resolved.ankiConnect.knownWords.addMinedWordsImmediately = - DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately; - } - - const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled); - if (nPlusOneEnabled !== undefined) { - context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled; - } else if (nPlusOneConfig.enabled !== undefined) { - context.warn( - 'ankiConnect.nPlusOne.enabled', - nPlusOneConfig.enabled, - context.resolved.ankiConnect.nPlusOne.enabled, - 'Expected boolean.', - ); - context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled; - } else { - context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled; - } - - const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords); - const hasValidNPlusOneMinSentenceWords = - nPlusOneMinSentenceWords !== undefined && - Number.isInteger(nPlusOneMinSentenceWords) && - nPlusOneMinSentenceWords > 0; - if (nPlusOneMinSentenceWords !== undefined) { - if (hasValidNPlusOneMinSentenceWords) { - context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords; - } else { - context.warn( - 'ankiConnect.nPlusOne.minSentenceWords', - nPlusOneConfig.minSentenceWords, - context.resolved.ankiConnect.nPlusOne.minSentenceWords, - 'Expected a positive integer.', - ); - context.resolved.ankiConnect.nPlusOne.minSentenceWords = - DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; - } - } else { - context.resolved.ankiConnect.nPlusOne.minSentenceWords = - DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; - } - - const knownWordsMatchMode = asString(knownWordsConfig.matchMode); - const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode); - const hasValidKnownWordsMatchMode = - knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface'; - const hasValidLegacyMatchMode = - legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface'; - if (hasValidKnownWordsMatchMode) { - context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode; - } else if (knownWordsMatchMode !== undefined) { - context.warn( - 'ankiConnect.knownWords.matchMode', - knownWordsConfig.matchMode, - DEFAULT_CONFIG.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; - context.warn( - 'ankiConnect.behavior.nPlusOneMatchMode', - behavior.nPlusOneMatchMode, - DEFAULT_CONFIG.ankiConnect.knownWords.matchMode, - 'Legacy key is deprecated; use ankiConnect.knownWords.matchMode', - ); - } else { - context.warn( - 'ankiConnect.behavior.nPlusOneMatchMode', - behavior.nPlusOneMatchMode, - context.resolved.ankiConnect.knownWords.matchMode, - "Expected 'headword' or 'surface'.", - ); - context.resolved.ankiConnect.knownWords.matchMode = - DEFAULT_CONFIG.ankiConnect.knownWords.matchMode; - } - } else { - context.resolved.ankiConnect.knownWords.matchMode = - DEFAULT_CONFIG.ankiConnect.knownWords.matchMode; - } - - const DEFAULT_FIELDS = [ - DEFAULT_CONFIG.ankiConnect.fields.word, - 'Word', - 'Reading', - 'Word Reading', - ]; - const knownWordsDecks = knownWordsConfig.decks; - 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); - 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, - resolved, - 'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }', - ); - } - } else if (knownWordsDecks !== undefined) { - context.warn( - 'ankiConnect.knownWords.decks', - knownWordsDecks, - context.resolved.ankiConnect.knownWords.decks, - 'Expected an object mapping deck names to field arrays.', - ); - } - - const rawSubtitleStyle = isObject(context.src.subtitleStyle) - ? (context.src.subtitleStyle as Record) - : {}; - const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined; - - const knownWordsColor = asColor(knownWordsConfig.color); - if (knownWordsColor !== undefined) { - if (!hasCanonicalKnownWordColor) { - context.resolved.subtitleStyle.knownWordColor = knownWordsColor; - } - context.warn( - 'ankiConnect.knownWords.color', - knownWordsConfig.color, - context.resolved.subtitleStyle.knownWordColor, - 'Legacy key is deprecated; use subtitleStyle.knownWordColor', - ); - } else if (knownWordsConfig.color !== undefined) { - context.warn( - 'ankiConnect.knownWords.color', - knownWordsConfig.color, - context.resolved.subtitleStyle.knownWordColor, - 'Expected a hex color value.', - ); - } - - if ( - context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' && - context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' && - context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled' - ) { - context.warn( - 'ankiConnect.isKiku.fieldGrouping', - context.resolved.ankiConnect.isKiku.fieldGrouping, - DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping, - 'Expected auto, manual, or disabled.', - ); - context.resolved.ankiConnect.isKiku.fieldGrouping = - DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping; - } + initializeAnkiConnectResolution(context, ankiConnect); + applyAnkiModernResolution(context, ankiConnect, behavior, media); + applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata); + applyAnkiKnownWordsResolution(context, ankiConnect, behavior); + applyAnkiKikuResolution(context); } diff --git a/src/config/resolve/anki-connect/ai.ts b/src/config/resolve/anki-connect/ai.ts new file mode 100644 index 00000000..97aada9d --- /dev/null +++ b/src/config/resolve/anki-connect/ai.ts @@ -0,0 +1,57 @@ +import type { ResolveContext } from '../context'; +import { asBoolean, asString, isObject } from '../shared'; + +export function applyAiResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + if (isObject(ankiConnect.ai)) { + const aiEnabled = asBoolean(ankiConnect.ai.enabled); + if (aiEnabled !== undefined) { + context.resolved.ankiConnect.ai.enabled = aiEnabled; + } else if (ankiConnect.ai.enabled !== undefined) { + context.warn( + 'ankiConnect.ai.enabled', + ankiConnect.ai.enabled, + context.resolved.ankiConnect.ai.enabled, + 'Expected boolean.', + ); + } + + const aiModel = asString(ankiConnect.ai.model); + if (aiModel !== undefined) { + context.resolved.ankiConnect.ai.model = aiModel; + } else if (ankiConnect.ai.model !== undefined) { + context.warn( + 'ankiConnect.ai.model', + ankiConnect.ai.model, + context.resolved.ankiConnect.ai.model, + 'Expected string.', + ); + } + + const aiSystemPrompt = asString(ankiConnect.ai.systemPrompt); + if (aiSystemPrompt !== undefined) { + context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt; + } else if (ankiConnect.ai.systemPrompt !== undefined) { + context.warn( + 'ankiConnect.ai.systemPrompt', + ankiConnect.ai.systemPrompt, + context.resolved.ankiConnect.ai.systemPrompt, + 'Expected string.', + ); + } + } else { + const aiEnabled = asBoolean(ankiConnect.ai); + if (aiEnabled !== undefined) { + context.resolved.ankiConnect.ai.enabled = aiEnabled; + } else if (ankiConnect.ai !== undefined) { + context.warn( + 'ankiConnect.ai', + ankiConnect.ai, + context.resolved.ankiConnect.ai.enabled, + 'Expected boolean or object.', + ); + } + } +} diff --git a/src/config/resolve/anki-connect/initialize.ts b/src/config/resolve/anki-connect/initialize.ts new file mode 100644 index 00000000..fac850f0 --- /dev/null +++ b/src/config/resolve/anki-connect/initialize.ts @@ -0,0 +1,81 @@ +import type { ResolveContext } from '../context'; +import { isObject } from '../shared'; + +const LEGACY_KEYS = new Set([ + 'wordField', + 'audioField', + 'imageField', + 'sentenceField', + 'miscInfoField', + 'miscInfoPattern', + 'generateAudio', + 'generateImage', + 'imageType', + 'imageFormat', + 'imageQuality', + 'imageMaxWidth', + 'imageMaxHeight', + 'animatedFps', + 'animatedMaxWidth', + 'animatedMaxHeight', + 'animatedCrf', + 'syncAnimatedImageToWordAudio', + 'audioPadding', + 'fallbackDuration', + 'maxMediaDuration', + 'overwriteAudio', + 'overwriteImage', + 'mediaInsertMode', + 'highlightWord', + 'notificationType', + 'autoUpdateNewCards', +]); + +export function initializeAnkiConnectResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + const { + knownWords: _knownWordsConfigFromAnkiConnect, + nPlusOne: _nPlusOneConfigFromAnkiConnect, + ai: _ankiAiConfig, + ...ankiConnectWithoutKnownWordsOrNPlusOne + } = ankiConnect; + const ankiConnectWithoutLegacy = Object.fromEntries( + Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !LEGACY_KEYS.has(key)), + ); + + context.resolved.ankiConnect = { + ...context.resolved.ankiConnect, + ...(isObject(ankiConnectWithoutLegacy) + ? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>) + : {}), + fields: { + ...context.resolved.ankiConnect.fields, + }, + media: { + ...context.resolved.ankiConnect.media, + }, + knownWords: { + ...context.resolved.ankiConnect.knownWords, + }, + behavior: { + ...context.resolved.ankiConnect.behavior, + }, + proxy: { + ...context.resolved.ankiConnect.proxy, + }, + metadata: { + ...context.resolved.ankiConnect.metadata, + }, + isLapis: { + ...context.resolved.ankiConnect.isLapis, + }, + isKiku: { + ...context.resolved.ankiConnect.isKiku, + ...(isObject(ankiConnect.isKiku) + ? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) + : {}), + }, + }; +} diff --git a/src/config/resolve/anki-connect/kiku.ts b/src/config/resolve/anki-connect/kiku.ts new file mode 100644 index 00000000..bce4ddb5 --- /dev/null +++ b/src/config/resolve/anki-connect/kiku.ts @@ -0,0 +1,19 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; + +export function applyAnkiKikuResolution(context: ResolveContext): void { + if ( + context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' && + context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' && + context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled' + ) { + context.warn( + 'ankiConnect.isKiku.fieldGrouping', + context.resolved.ankiConnect.isKiku.fieldGrouping, + DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping, + 'Expected auto, manual, or disabled.', + ); + context.resolved.ankiConnect.isKiku.fieldGrouping = + DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping; + } +} diff --git a/src/config/resolve/anki-connect/known-words.ts b/src/config/resolve/anki-connect/known-words.ts new file mode 100644 index 00000000..31bbf5c6 --- /dev/null +++ b/src/config/resolve/anki-connect/known-words.ts @@ -0,0 +1,267 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asBoolean, asColor, asNumber, asString, isObject } from '../shared'; +import { hasOwn } from './shared'; + +export function applyAnkiKnownWordsResolution( + context: ResolveContext, + ankiConnect: Record, + behavior: Record, +): void { + const knownWordsConfig = isObject(ankiConnect.knownWords) ? ankiConnect.knownWords : {}; + const nPlusOneConfig = isObject(ankiConnect.nPlusOne) ? ankiConnect.nPlusOne : {}; + + const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled); + if (knownWordsHighlightEnabled !== undefined) { + context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled; + } else if (hasOwn(knownWordsConfig, 'highlightEnabled')) { + context.warn( + 'ankiConnect.knownWords.highlightEnabled', + knownWordsConfig.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) { + context.resolved.ankiConnect.knownWords.highlightEnabled = + legacyBehaviorNPlusOneHighlightEnabled; + context.warn( + 'ankiConnect.behavior.nPlusOneHighlightEnabled', + behavior.nPlusOneHighlightEnabled, + DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled, + 'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled', + ); + } else if (hasOwn(behavior, 'nPlusOneHighlightEnabled')) { + context.warn( + 'ankiConnect.behavior.nPlusOneHighlightEnabled', + behavior.nPlusOneHighlightEnabled, + DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled, + 'Expected boolean.', + ); + context.resolved.ankiConnect.knownWords.highlightEnabled = + DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled; + } else { + context.resolved.ankiConnect.knownWords.highlightEnabled = + DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled; + } + } + + const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes); + const hasValidKnownWordsRefreshMinutes = + knownWordsRefreshMinutes !== undefined && + Number.isInteger(knownWordsRefreshMinutes) && + knownWordsRefreshMinutes > 0; + if (hasOwn(knownWordsConfig, 'refreshMinutes')) { + if (hasValidKnownWordsRefreshMinutes) { + context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes; + } else { + context.warn( + 'ankiConnect.knownWords.refreshMinutes', + knownWordsConfig.refreshMinutes, + context.resolved.ankiConnect.knownWords.refreshMinutes, + 'Expected a positive integer.', + ); + context.resolved.ankiConnect.knownWords.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes; + } + } else if (hasOwn(behavior, 'nPlusOneRefreshMinutes')) { + const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes); + const hasValidLegacyRefreshMinutes = + legacyBehaviorNPlusOneRefreshMinutes !== undefined && + Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) && + legacyBehaviorNPlusOneRefreshMinutes > 0; + if (hasValidLegacyRefreshMinutes) { + context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes; + context.warn( + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + behavior.nPlusOneRefreshMinutes, + DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes, + 'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes', + ); + } else { + context.warn( + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + behavior.nPlusOneRefreshMinutes, + context.resolved.ankiConnect.knownWords.refreshMinutes, + 'Expected a positive integer.', + ); + context.resolved.ankiConnect.knownWords.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes; + } + } else { + context.resolved.ankiConnect.knownWords.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes; + } + + const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately); + if (knownWordsAddMinedWordsImmediately !== undefined) { + context.resolved.ankiConnect.knownWords.addMinedWordsImmediately = + knownWordsAddMinedWordsImmediately; + } else if (knownWordsConfig.addMinedWordsImmediately !== undefined) { + context.warn( + 'ankiConnect.knownWords.addMinedWordsImmediately', + knownWordsConfig.addMinedWordsImmediately, + context.resolved.ankiConnect.knownWords.addMinedWordsImmediately, + 'Expected boolean.', + ); + context.resolved.ankiConnect.knownWords.addMinedWordsImmediately = + DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately; + } else { + context.resolved.ankiConnect.knownWords.addMinedWordsImmediately = + DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately; + } + + const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled); + if (nPlusOneEnabled !== undefined) { + context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled; + } else if (nPlusOneConfig.enabled !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.enabled', + nPlusOneConfig.enabled, + context.resolved.ankiConnect.nPlusOne.enabled, + 'Expected boolean.', + ); + context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled; + } else { + context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled; + } + + const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords); + const hasValidNPlusOneMinSentenceWords = + nPlusOneMinSentenceWords !== undefined && + Number.isInteger(nPlusOneMinSentenceWords) && + nPlusOneMinSentenceWords > 0; + if (hasOwn(nPlusOneConfig, 'minSentenceWords')) { + if (hasValidNPlusOneMinSentenceWords) { + context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords; + } else { + context.warn( + 'ankiConnect.nPlusOne.minSentenceWords', + nPlusOneConfig.minSentenceWords, + context.resolved.ankiConnect.nPlusOne.minSentenceWords, + 'Expected a positive integer.', + ); + context.resolved.ankiConnect.nPlusOne.minSentenceWords = + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; + } + } else { + context.resolved.ankiConnect.nPlusOne.minSentenceWords = + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; + } + + const knownWordsMatchMode = asString(knownWordsConfig.matchMode); + const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode); + const hasValidKnownWordsMatchMode = + knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface'; + const hasValidLegacyMatchMode = + legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface'; + if (hasValidKnownWordsMatchMode) { + context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode; + } else if (hasOwn(knownWordsConfig, 'matchMode')) { + context.warn( + 'ankiConnect.knownWords.matchMode', + knownWordsConfig.matchMode, + DEFAULT_CONFIG.ankiConnect.knownWords.matchMode, + "Expected 'headword' or 'surface'.", + ); + context.resolved.ankiConnect.knownWords.matchMode = + DEFAULT_CONFIG.ankiConnect.knownWords.matchMode; + } else if (hasOwn(behavior, 'nPlusOneMatchMode')) { + if (hasValidLegacyMatchMode) { + context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode; + context.warn( + 'ankiConnect.behavior.nPlusOneMatchMode', + behavior.nPlusOneMatchMode, + DEFAULT_CONFIG.ankiConnect.knownWords.matchMode, + 'Legacy key is deprecated; use ankiConnect.knownWords.matchMode', + ); + } else { + context.warn( + 'ankiConnect.behavior.nPlusOneMatchMode', + behavior.nPlusOneMatchMode, + context.resolved.ankiConnect.knownWords.matchMode, + "Expected 'headword' or 'surface'.", + ); + context.resolved.ankiConnect.knownWords.matchMode = + DEFAULT_CONFIG.ankiConnect.knownWords.matchMode; + } + } else { + context.resolved.ankiConnect.knownWords.matchMode = + DEFAULT_CONFIG.ankiConnect.knownWords.matchMode; + } + + const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading']; + const knownWordsDecks = knownWordsConfig.decks; + if (isObject(knownWordsDecks)) { + const resolved: Record = {}; + for (const [deck, fields] of Object.entries(knownWordsDecks)) { + const deckName = deck.trim(); + if (!deckName) continue; + if (Array.isArray(fields) && fields.every((field) => typeof field === 'string')) { + resolved[deckName] = fields + .map((field) => field.trim()) + .filter((field) => field.length > 0); + } else { + context.warn( + `ankiConnect.knownWords.decks["${deckName}"]`, + fields, + defaultFields, + 'Expected an array of field name strings.', + ); + resolved[deckName] = defaultFields; + } + } + 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); + const resolved: Record = {}; + for (const deck of new Set(normalized)) { + resolved[deck] = defaultFields; + } + context.resolved.ankiConnect.knownWords.decks = resolved; + if (normalized.length > 0) { + context.warn( + 'ankiConnect.knownWords.decks', + knownWordsDecks, + resolved, + 'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }', + ); + } + } else if (knownWordsDecks !== undefined) { + context.warn( + 'ankiConnect.knownWords.decks', + knownWordsDecks, + context.resolved.ankiConnect.knownWords.decks, + 'Expected an object mapping deck names to field arrays.', + ); + } + + const rawSubtitleStyle = isObject(context.src.subtitleStyle) ? context.src.subtitleStyle : {}; + const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined; + + const knownWordsColor = asColor(knownWordsConfig.color); + if (knownWordsColor !== undefined) { + if (!hasCanonicalKnownWordColor) { + context.resolved.subtitleStyle.knownWordColor = knownWordsColor; + } + context.warn( + 'ankiConnect.knownWords.color', + knownWordsConfig.color, + context.resolved.subtitleStyle.knownWordColor, + 'Legacy key is deprecated; use subtitleStyle.knownWordColor', + ); + } else if (knownWordsConfig.color !== undefined) { + context.warn( + 'ankiConnect.knownWords.color', + knownWordsConfig.color, + context.resolved.subtitleStyle.knownWordColor, + 'Expected a hex color value.', + ); + } +} diff --git a/src/config/resolve/anki-connect/lapis.ts b/src/config/resolve/anki-connect/lapis.ts new file mode 100644 index 00000000..e57d8c14 --- /dev/null +++ b/src/config/resolve/anki-connect/lapis.ts @@ -0,0 +1,58 @@ +import type { ResolveContext } from '../context'; +import { asBoolean, asString, isObject } from '../shared'; + +export function applyLapisResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + if (isObject(ankiConnect.isLapis)) { + const lapisEnabled = asBoolean(ankiConnect.isLapis.enabled); + if (lapisEnabled !== undefined) { + context.resolved.ankiConnect.isLapis.enabled = lapisEnabled; + } else if (ankiConnect.isLapis.enabled !== undefined) { + context.warn( + 'ankiConnect.isLapis.enabled', + ankiConnect.isLapis.enabled, + context.resolved.ankiConnect.isLapis.enabled, + 'Expected boolean.', + ); + } + + const sentenceCardModel = asString(ankiConnect.isLapis.sentenceCardModel); + if (sentenceCardModel !== undefined) { + context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel; + } else if (ankiConnect.isLapis.sentenceCardModel !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardModel', + ankiConnect.isLapis.sentenceCardModel, + context.resolved.ankiConnect.isLapis.sentenceCardModel, + 'Expected string.', + ); + } + + if (ankiConnect.isLapis.sentenceCardSentenceField !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardSentenceField', + ankiConnect.isLapis.sentenceCardSentenceField, + 'Sentence', + 'Deprecated key; sentence-card sentence field is fixed to Sentence.', + ); + } + + if (ankiConnect.isLapis.sentenceCardAudioField !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardAudioField', + ankiConnect.isLapis.sentenceCardAudioField, + 'SentenceAudio', + 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.', + ); + } + } else if (ankiConnect.isLapis !== undefined) { + context.warn( + 'ankiConnect.isLapis', + ankiConnect.isLapis, + context.resolved.ankiConnect.isLapis, + 'Expected object.', + ); + } +} diff --git a/src/config/resolve/anki-connect/legacy.ts b/src/config/resolve/anki-connect/legacy.ts new file mode 100644 index 00000000..373c0de0 --- /dev/null +++ b/src/config/resolve/anki-connect/legacy.ts @@ -0,0 +1,364 @@ +import type { ResolveContext } from '../context'; +import { asBoolean, asNumber, asString } from '../shared'; +import { asNotificationType, hasOwn } from './shared'; + +export function applyAnkiLegacyResolution( + context: ResolveContext, + legacy: Record, + behavior: Record, + fields: Record, + media: Record, + metadata: Record, +): void { + const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) { + return undefined; + } + return parsed; + }; + const asPositiveInteger = (value: unknown): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) { + return undefined; + } + return parsed; + }; + const asPositiveNumber = (value: unknown): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || parsed <= 0) { + return undefined; + } + return parsed; + }; + const asNonNegativeNumber = (value: unknown): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || parsed < 0) { + return undefined; + } + return parsed; + }; + const asImageType = (value: unknown): 'static' | 'avif' | undefined => { + return value === 'static' || value === 'avif' ? value : undefined; + }; + const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => { + return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined; + }; + const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => { + return value === 'append' || value === 'prepend' ? value : undefined; + }; + const mapLegacy = ( + key: string, + parse: (value: unknown) => T | undefined, + apply: (value: T) => void, + fallback: unknown, + message: string, + ): void => { + const value = legacy[key]; + if (value === undefined) return; + const parsed = parse(value); + if (parsed === undefined) { + context.warn(`ankiConnect.${key}`, value, fallback, message); + return; + } + apply(parsed); + }; + + if (!hasOwn(fields, 'audio')) { + mapLegacy( + 'audioField', + asString, + (value) => { + context.resolved.ankiConnect.fields.audio = value; + }, + context.resolved.ankiConnect.fields.audio, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'word')) { + mapLegacy( + 'wordField', + asString, + (value) => { + context.resolved.ankiConnect.fields.word = value; + }, + context.resolved.ankiConnect.fields.word, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'image')) { + mapLegacy( + 'imageField', + asString, + (value) => { + context.resolved.ankiConnect.fields.image = value; + }, + context.resolved.ankiConnect.fields.image, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'sentence')) { + mapLegacy( + 'sentenceField', + asString, + (value) => { + context.resolved.ankiConnect.fields.sentence = value; + }, + context.resolved.ankiConnect.fields.sentence, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'miscInfo')) { + mapLegacy( + 'miscInfoField', + asString, + (value) => { + context.resolved.ankiConnect.fields.miscInfo = value; + }, + context.resolved.ankiConnect.fields.miscInfo, + 'Expected string.', + ); + } + if (!hasOwn(metadata, 'pattern')) { + mapLegacy( + 'miscInfoPattern', + asString, + (value) => { + context.resolved.ankiConnect.metadata.pattern = value; + }, + context.resolved.ankiConnect.metadata.pattern, + 'Expected string.', + ); + } + if (!hasOwn(media, 'generateAudio')) { + mapLegacy( + 'generateAudio', + asBoolean, + (value) => { + context.resolved.ankiConnect.media.generateAudio = value; + }, + context.resolved.ankiConnect.media.generateAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'generateImage')) { + mapLegacy( + 'generateImage', + asBoolean, + (value) => { + context.resolved.ankiConnect.media.generateImage = value; + }, + context.resolved.ankiConnect.media.generateImage, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'imageType')) { + mapLegacy( + 'imageType', + asImageType, + (value) => { + context.resolved.ankiConnect.media.imageType = value; + }, + context.resolved.ankiConnect.media.imageType, + "Expected 'static' or 'avif'.", + ); + } + if (!hasOwn(media, 'imageFormat')) { + mapLegacy( + 'imageFormat', + asImageFormat, + (value) => { + context.resolved.ankiConnect.media.imageFormat = value; + }, + context.resolved.ankiConnect.media.imageFormat, + "Expected 'jpg', 'png', or 'webp'.", + ); + } + if (!hasOwn(media, 'imageQuality')) { + mapLegacy( + 'imageQuality', + (value) => asIntegerInRange(value, 1, 100), + (value) => { + context.resolved.ankiConnect.media.imageQuality = value; + }, + context.resolved.ankiConnect.media.imageQuality, + 'Expected integer between 1 and 100.', + ); + } + if (!hasOwn(media, 'imageMaxWidth')) { + mapLegacy( + 'imageMaxWidth', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.imageMaxWidth = value; + }, + context.resolved.ankiConnect.media.imageMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'imageMaxHeight')) { + mapLegacy( + 'imageMaxHeight', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.imageMaxHeight = value; + }, + context.resolved.ankiConnect.media.imageMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedFps')) { + mapLegacy( + 'animatedFps', + (value) => asIntegerInRange(value, 1, 60), + (value) => { + context.resolved.ankiConnect.media.animatedFps = value; + }, + context.resolved.ankiConnect.media.animatedFps, + 'Expected integer between 1 and 60.', + ); + } + if (!hasOwn(media, 'animatedMaxWidth')) { + mapLegacy( + 'animatedMaxWidth', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.animatedMaxWidth = value; + }, + context.resolved.ankiConnect.media.animatedMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedMaxHeight')) { + mapLegacy( + 'animatedMaxHeight', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.animatedMaxHeight = value; + }, + context.resolved.ankiConnect.media.animatedMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedCrf')) { + mapLegacy( + 'animatedCrf', + (value) => asIntegerInRange(value, 0, 63), + (value) => { + context.resolved.ankiConnect.media.animatedCrf = value; + }, + context.resolved.ankiConnect.media.animatedCrf, + 'Expected integer between 0 and 63.', + ); + } + if (!hasOwn(media, 'syncAnimatedImageToWordAudio')) { + mapLegacy( + 'syncAnimatedImageToWordAudio', + asBoolean, + (value) => { + context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio = value; + }, + context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'audioPadding')) { + mapLegacy( + 'audioPadding', + asNonNegativeNumber, + (value) => { + context.resolved.ankiConnect.media.audioPadding = value; + }, + context.resolved.ankiConnect.media.audioPadding, + 'Expected non-negative number.', + ); + } + if (!hasOwn(media, 'fallbackDuration')) { + mapLegacy( + 'fallbackDuration', + asPositiveNumber, + (value) => { + context.resolved.ankiConnect.media.fallbackDuration = value; + }, + context.resolved.ankiConnect.media.fallbackDuration, + 'Expected positive number.', + ); + } + if (!hasOwn(media, 'maxMediaDuration')) { + mapLegacy( + 'maxMediaDuration', + asNonNegativeNumber, + (value) => { + context.resolved.ankiConnect.media.maxMediaDuration = value; + }, + context.resolved.ankiConnect.media.maxMediaDuration, + 'Expected non-negative number.', + ); + } + if (!hasOwn(behavior, 'overwriteAudio')) { + mapLegacy( + 'overwriteAudio', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.overwriteAudio = value; + }, + context.resolved.ankiConnect.behavior.overwriteAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'overwriteImage')) { + mapLegacy( + 'overwriteImage', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.overwriteImage = value; + }, + context.resolved.ankiConnect.behavior.overwriteImage, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'mediaInsertMode')) { + mapLegacy( + 'mediaInsertMode', + asMediaInsertMode, + (value) => { + context.resolved.ankiConnect.behavior.mediaInsertMode = value; + }, + context.resolved.ankiConnect.behavior.mediaInsertMode, + "Expected 'append' or 'prepend'.", + ); + } + if (!hasOwn(behavior, 'highlightWord')) { + mapLegacy( + 'highlightWord', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.highlightWord = value; + }, + context.resolved.ankiConnect.behavior.highlightWord, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'notificationType')) { + mapLegacy( + 'notificationType', + asNotificationType, + (value) => { + context.resolved.ankiConnect.behavior.notificationType = value; + }, + context.resolved.ankiConnect.behavior.notificationType, + "Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.", + ); + } + if (!hasOwn(behavior, 'autoUpdateNewCards')) { + mapLegacy( + 'autoUpdateNewCards', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.autoUpdateNewCards = value; + }, + context.resolved.ankiConnect.behavior.autoUpdateNewCards, + 'Expected boolean.', + ); + } +} diff --git a/src/config/resolve/anki-connect/modern-behavior.ts b/src/config/resolve/anki-connect/modern-behavior.ts new file mode 100644 index 00000000..9fd5630f --- /dev/null +++ b/src/config/resolve/anki-connect/modern-behavior.ts @@ -0,0 +1,55 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asBoolean } from '../shared'; +import { applyModernValue } from './modern-value'; +import { asNotificationType } from './shared'; + +export function applyModernBehaviorResolution( + context: ResolveContext, + behavior: Record, +): void { + for (const key of [ + 'overwriteAudio', + 'overwriteImage', + 'highlightWord', + 'autoUpdateNewCards', + ] as const) { + applyModernValue( + context, + behavior, + key, + `ankiConnect.behavior.${key}`, + asBoolean, + DEFAULT_CONFIG.ankiConnect.behavior[key], + (value) => { + context.resolved.ankiConnect.behavior[key] = value; + }, + 'Expected boolean.', + ); + } + + applyModernValue( + context, + behavior, + 'mediaInsertMode', + 'ankiConnect.behavior.mediaInsertMode', + (value) => (value === 'append' || value === 'prepend' ? value : undefined), + DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode, + (value) => { + context.resolved.ankiConnect.behavior.mediaInsertMode = value; + }, + "Expected 'append' or 'prepend'.", + ); + applyModernValue( + context, + behavior, + 'notificationType', + 'ankiConnect.behavior.notificationType', + asNotificationType, + DEFAULT_CONFIG.ankiConnect.behavior.notificationType, + (value) => { + context.resolved.ankiConnect.behavior.notificationType = value; + }, + "Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.", + ); +} diff --git a/src/config/resolve/anki-connect/modern-fields.ts b/src/config/resolve/anki-connect/modern-fields.ts new file mode 100644 index 00000000..c8d30ad1 --- /dev/null +++ b/src/config/resolve/anki-connect/modern-fields.ts @@ -0,0 +1,24 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asString } from '../shared'; +import { applyModernValue } from './modern-value'; + +export function applyModernFieldsResolution( + context: ResolveContext, + fields: Record, +): void { + for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) { + applyModernValue( + context, + fields, + key, + `ankiConnect.fields.${key}`, + asString, + DEFAULT_CONFIG.ankiConnect.fields[key], + (value) => { + context.resolved.ankiConnect.fields[key] = value; + }, + 'Expected string.', + ); + } +} diff --git a/src/config/resolve/anki-connect/modern-media.ts b/src/config/resolve/anki-connect/modern-media.ts new file mode 100644 index 00000000..d391f1c0 --- /dev/null +++ b/src/config/resolve/anki-connect/modern-media.ts @@ -0,0 +1,145 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asBoolean } from '../shared'; +import { + applyModernValue, + asIntegerInRange, + asNonNegativeInteger, + asNonNegativeNumber, + asPositiveNumber, +} from './modern-value'; + +export function applyModernMediaResolution( + context: ResolveContext, + media: Record, +): void { + for (const key of [ + 'generateAudio', + 'generateImage', + 'syncAnimatedImageToWordAudio', + 'normalizeAudio', + 'mirrorMpvVolume', + ] as const) { + applyModernValue( + context, + media, + key, + `ankiConnect.media.${key}`, + asBoolean, + DEFAULT_CONFIG.ankiConnect.media[key], + (value) => { + context.resolved.ankiConnect.media[key] = value; + }, + 'Expected boolean.', + ); + } + + applyModernValue( + context, + media, + 'imageType', + 'ankiConnect.media.imageType', + (value) => (value === 'static' || value === 'avif' ? value : undefined), + DEFAULT_CONFIG.ankiConnect.media.imageType, + (value) => { + context.resolved.ankiConnect.media.imageType = value; + }, + "Expected 'static' or 'avif'.", + ); + applyModernValue( + context, + media, + 'imageFormat', + 'ankiConnect.media.imageFormat', + (value) => (value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined), + DEFAULT_CONFIG.ankiConnect.media.imageFormat, + (value) => { + context.resolved.ankiConnect.media.imageFormat = value; + }, + "Expected 'jpg', 'png', or 'webp'.", + ); + applyModernValue( + context, + media, + 'imageQuality', + 'ankiConnect.media.imageQuality', + (value) => asIntegerInRange(value, 1, 100), + DEFAULT_CONFIG.ankiConnect.media.imageQuality, + (value) => { + context.resolved.ankiConnect.media.imageQuality = value; + }, + 'Expected integer between 1 and 100.', + ); + + for (const key of [ + 'imageMaxWidth', + 'imageMaxHeight', + 'animatedMaxWidth', + 'animatedMaxHeight', + ] as const) { + applyModernValue( + context, + media, + key, + `ankiConnect.media.${key}`, + asNonNegativeInteger, + DEFAULT_CONFIG.ankiConnect.media[key] ?? 0, + (value) => { + context.resolved.ankiConnect.media[key] = value; + }, + 'Expected non-negative integer.', + ); + } + + applyModernValue( + context, + media, + 'animatedFps', + 'ankiConnect.media.animatedFps', + (value) => asIntegerInRange(value, 1, 60), + DEFAULT_CONFIG.ankiConnect.media.animatedFps, + (value) => { + context.resolved.ankiConnect.media.animatedFps = value; + }, + 'Expected integer between 1 and 60.', + ); + applyModernValue( + context, + media, + 'animatedCrf', + 'ankiConnect.media.animatedCrf', + (value) => asIntegerInRange(value, 0, 63), + DEFAULT_CONFIG.ankiConnect.media.animatedCrf, + (value) => { + context.resolved.ankiConnect.media.animatedCrf = value; + }, + 'Expected integer between 0 and 63.', + ); + applyModernValue( + context, + media, + 'audioPadding', + 'ankiConnect.media.audioPadding', + asNonNegativeNumber, + DEFAULT_CONFIG.ankiConnect.media.audioPadding, + (value) => { + context.resolved.ankiConnect.media.audioPadding = value; + }, + 'Expected non-negative number.', + ); + + for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) { + applyModernValue( + context, + media, + key, + `ankiConnect.media.${key}`, + asPositiveNumber, + DEFAULT_CONFIG.ankiConnect.media[key], + (value) => { + context.resolved.ankiConnect.media[key] = value; + }, + 'Expected positive number.', + ); + } +} diff --git a/src/config/resolve/anki-connect/modern-metadata.ts b/src/config/resolve/anki-connect/modern-metadata.ts new file mode 100644 index 00000000..fbb98caf --- /dev/null +++ b/src/config/resolve/anki-connect/modern-metadata.ts @@ -0,0 +1,22 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asString } from '../shared'; +import { applyModernValue } from './modern-value'; + +export function applyModernMetadataResolution( + context: ResolveContext, + metadata: Record, +): void { + applyModernValue( + context, + metadata, + 'pattern', + 'ankiConnect.metadata.pattern', + asString, + DEFAULT_CONFIG.ankiConnect.metadata.pattern, + (value) => { + context.resolved.ankiConnect.metadata.pattern = value; + }, + 'Expected string.', + ); +} diff --git a/src/config/resolve/anki-connect/modern-value.ts b/src/config/resolve/anki-connect/modern-value.ts new file mode 100644 index 00000000..2a644113 --- /dev/null +++ b/src/config/resolve/anki-connect/modern-value.ts @@ -0,0 +1,46 @@ +import type { ResolveContext } from '../context'; +import { asNumber } from '../shared'; +import { hasOwn } from './shared'; + +export function asIntegerInRange(value: unknown, min: number, max: number): number | undefined { + const parsed = asNumber(value); + return parsed !== undefined && Number.isInteger(parsed) && parsed >= min && parsed <= max + ? parsed + : undefined; +} + +export function asNonNegativeInteger(value: unknown): number | undefined { + const parsed = asNumber(value); + return parsed !== undefined && Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +export function asPositiveNumber(value: unknown): number | undefined { + const parsed = asNumber(value); + return parsed !== undefined && parsed > 0 ? parsed : undefined; +} + +export function asNonNegativeNumber(value: unknown): number | undefined { + const parsed = asNumber(value); + return parsed !== undefined && parsed >= 0 ? parsed : undefined; +} + +export function applyModernValue( + context: ResolveContext, + source: Record, + key: string, + path: string, + parse: (value: unknown) => T | undefined, + fallback: T, + apply: (value: T) => void, + message: string, +): void { + if (!hasOwn(source, key)) return; + const raw = source[key]; + const parsed = parse(raw); + if (parsed === undefined) { + apply(fallback); + context.warn(path, raw, fallback, message); + return; + } + apply(parsed); +} diff --git a/src/config/resolve/anki-connect/modern.ts b/src/config/resolve/anki-connect/modern.ts new file mode 100644 index 00000000..bb240cea --- /dev/null +++ b/src/config/resolve/anki-connect/modern.ts @@ -0,0 +1,29 @@ +import type { ResolveContext } from '../context'; +import { isObject } from '../shared'; +import { applyAiResolution } from './ai'; +import { applyLapisResolution } from './lapis'; +import { applyModernBehaviorResolution } from './modern-behavior'; +import { applyModernFieldsResolution } from './modern-fields'; +import { applyModernMediaResolution } from './modern-media'; +import { applyModernMetadataResolution } from './modern-metadata'; +import { applyProxyResolution } from './proxy'; +import { applyTagsResolution } from './tags'; + +export function applyAnkiModernResolution( + context: ResolveContext, + ankiConnect: Record, + behavior: Record, + media: Record, +): void { + const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {}; + const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {}; + + applyModernFieldsResolution(context, fields); + applyModernMediaResolution(context, media); + applyModernBehaviorResolution(context, behavior); + applyModernMetadataResolution(context, metadata); + applyLapisResolution(context, ankiConnect); + applyProxyResolution(context, ankiConnect); + applyAiResolution(context, ankiConnect); + applyTagsResolution(context, ankiConnect); +} diff --git a/src/config/resolve/anki-connect/proxy.ts b/src/config/resolve/anki-connect/proxy.ts new file mode 100644 index 00000000..44830988 --- /dev/null +++ b/src/config/resolve/anki-connect/proxy.ts @@ -0,0 +1,70 @@ +import type { ResolveContext } from '../context'; +import { asBoolean, asNumber, asString, isObject } from '../shared'; + +export function applyProxyResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + if (isObject(ankiConnect.proxy)) { + const proxy = ankiConnect.proxy; + const proxyEnabled = asBoolean(proxy.enabled); + if (proxyEnabled !== undefined) { + context.resolved.ankiConnect.proxy.enabled = proxyEnabled; + } else if (proxy.enabled !== undefined) { + context.warn( + 'ankiConnect.proxy.enabled', + proxy.enabled, + context.resolved.ankiConnect.proxy.enabled, + 'Expected boolean.', + ); + } + + const proxyHost = asString(proxy.host); + if (proxyHost !== undefined && proxyHost.trim().length > 0) { + context.resolved.ankiConnect.proxy.host = proxyHost.trim(); + } else if (proxy.host !== undefined) { + context.warn( + 'ankiConnect.proxy.host', + proxy.host, + context.resolved.ankiConnect.proxy.host, + 'Expected non-empty string.', + ); + } + + const proxyUpstreamUrl = asString(proxy.upstreamUrl); + if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) { + context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim(); + } else if (proxy.upstreamUrl !== undefined) { + context.warn( + 'ankiConnect.proxy.upstreamUrl', + proxy.upstreamUrl, + context.resolved.ankiConnect.proxy.upstreamUrl, + 'Expected non-empty string.', + ); + } + + const proxyPort = asNumber(proxy.port); + if ( + proxyPort !== undefined && + Number.isInteger(proxyPort) && + proxyPort >= 1 && + proxyPort <= 65535 + ) { + context.resolved.ankiConnect.proxy.port = proxyPort; + } else if (proxy.port !== undefined) { + context.warn( + 'ankiConnect.proxy.port', + proxy.port, + context.resolved.ankiConnect.proxy.port, + 'Expected integer between 1 and 65535.', + ); + } + } else if (ankiConnect.proxy !== undefined) { + context.warn( + 'ankiConnect.proxy', + ankiConnect.proxy, + context.resolved.ankiConnect.proxy, + 'Expected object.', + ); + } +} diff --git a/src/config/resolve/anki-connect/shared.ts b/src/config/resolve/anki-connect/shared.ts new file mode 100644 index 00000000..07b9d447 --- /dev/null +++ b/src/config/resolve/anki-connect/shared.ts @@ -0,0 +1,9 @@ +import { isNotificationType, type NotificationType } from '../../../types/notification'; + +export function asNotificationType(value: unknown): NotificationType | undefined { + return isNotificationType(value) ? value : undefined; +} + +export function hasOwn(obj: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(obj, key); +} diff --git a/src/config/resolve/anki-connect/tags.ts b/src/config/resolve/anki-connect/tags.ts new file mode 100644 index 00000000..3e204f98 --- /dev/null +++ b/src/config/resolve/anki-connect/tags.ts @@ -0,0 +1,33 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; + +export function applyTagsResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + if (Array.isArray(ankiConnect.tags)) { + const normalizedTags = ankiConnect.tags + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (normalizedTags.length === ankiConnect.tags.length) { + context.resolved.ankiConnect.tags = [...new Set(normalizedTags)]; + } else { + context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + context.warn( + 'ankiConnect.tags', + ankiConnect.tags, + context.resolved.ankiConnect.tags, + 'Expected an array of non-empty strings.', + ); + } + } else if (ankiConnect.tags !== undefined) { + context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + context.warn( + 'ankiConnect.tags', + ankiConnect.tags, + context.resolved.ankiConnect.tags, + 'Expected an array of strings.', + ); + } +} diff --git a/src/core/services/__tests__/stats-server-mining-support.test.ts b/src/core/services/__tests__/stats-server-mining-support.test.ts new file mode 100644 index 00000000..640c7a6f --- /dev/null +++ b/src/core/services/__tests__/stats-server-mining-support.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createStatsMiningContext } from '../stats-server/mining-support.js'; + +test('mining timing observer errors do not replace successful phase results', async () => { + const { timeMiningPhase } = createStatsMiningContext({ + onMiningTiming: () => { + throw new Error('observer failed'); + }, + }); + + const result = await timeMiningPhase('word', 'test', async () => 42); + + assert.equal(result, 42); +}); + +test('mining timing observer errors do not replace phase errors', async () => { + const phaseError = new Error('phase failed'); + const { timeMiningPhase } = createStatsMiningContext({ + onMiningTiming: () => { + throw new Error('observer failed'); + }, + }); + + await assert.rejects( + timeMiningPhase('word', 'test', async () => { + throw phaseError; + }), + (error: unknown) => error === phaseError, + ); +}); diff --git a/src/core/services/__tests__/stats-server-route-groups.test.ts b/src/core/services/__tests__/stats-server-route-groups.test.ts new file mode 100644 index 00000000..9980ce7c --- /dev/null +++ b/src/core/services/__tests__/stats-server-route-groups.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + registerStatsAnalyticsRoutes, + registerStatsIntegrationRoutes, + registerStatsLibraryRoutes, + registerStatsMiningRoutes, + registerStatsStaticRoutes, +} from '../stats-server/routes.js'; + +describe('stats server route groups', () => { + test('exposes focused registrars for createStatsApp composition', () => { + for (const registrar of [ + registerStatsAnalyticsRoutes, + registerStatsLibraryRoutes, + registerStatsIntegrationRoutes, + registerStatsMiningRoutes, + registerStatsStaticRoutes, + ]) { + assert.equal(typeof registrar, 'function'); + } + }); +}); diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 81e4ab46..f8d1b706 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -900,9 +900,11 @@ describe('stats server API routes', () => { }), ); - const res = await app.request('/api/stats/vocabulary?excludePos=particle,auxiliary'); + const res = await app.request( + '/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20', + ); assert.equal(res.status, 200); - assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary']]); + assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]); }); it('GET /api/stats/vocabulary returns POS fields', async () => { @@ -1001,6 +1003,36 @@ describe('stats server API routes', () => { assert.equal(res.status, 404); }); + it('PATCH /api/stats/anime/:animeId/anilist accepts only positive integer AniList ids', async () => { + const assignments: Array<{ animeId: number; body: unknown }> = []; + const app = createStatsApp( + createMockTracker({ + reassignAnimeAnilist: async (animeId: number, body: unknown) => { + assignments.push({ animeId, body }); + }, + }), + ); + + for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) { + const res = await app.request('/api/stats/anime/1/anilist', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ anilistId }), + }); + assert.equal(res.status, 400, `accepted invalid AniList id: ${String(anilistId)}`); + } + assert.deepEqual(assignments, []); + + const body = { anilistId: 21_802, titleRomaji: 'Little Witch Academia' }; + const res = await app.request('/api/stats/anime/1/anilist', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + assert.equal(res.status, 200); + assert.deepEqual(assignments, [{ animeId: 1, body }]); + }); + it('GET /api/stats/anime/:animeId/cover returns cover art', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/anime/1/cover'); @@ -1348,6 +1380,86 @@ describe('stats server API routes', () => { }); }); + it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + let yomitanCalls = 0; + let mediaCalls = 0; + const app = createStatsApp(createMockTracker(), { + addYomitanNote: async () => { + yomitanCalls += 1; + return 777; + }, + createMediaGenerator: () => ({ + generateAudio: async () => { + mediaCalls += 1; + return Buffer.from('audio'); + }, + generateScreenshot: async () => { + mediaCalls += 1; + return Buffer.from('image'); + }, + generateAnimatedImage: async () => null, + }), + ankiConnectConfig: { media: { generateAudio: true, generateImage: true } }, + }); + + const res = await app.request('/api/stats/mine-card?mode=word', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 1_000, + endMs: 2_000, + sentence: '猫を見た', + word: ' ', + }), + }); + + assert.equal(res.status, 400); + assert.equal(yomitanCalls, 0); + assert.equal(mediaCalls, 0); + }); + }); + + it('POST /api/stats/mine-card does not start word media without a Yomitan bridge', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + let mediaCalls = 0; + const app = createStatsApp(createMockTracker(), { + createMediaGenerator: () => ({ + generateAudio: async () => { + mediaCalls += 1; + return Buffer.from('audio'); + }, + generateScreenshot: async () => { + mediaCalls += 1; + return Buffer.from('image'); + }, + generateAnimatedImage: async () => null, + }), + ankiConnectConfig: { media: { generateAudio: true, generateImage: true } }, + }); + + const res = await app.request('/api/stats/mine-card?mode=word', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 1_000, + endMs: 2_000, + sentence: '猫を見た', + word: '猫', + }), + }); + + assert.equal(res.status, 500); + assert.equal(mediaCalls, 0); + }); + }); + it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); @@ -2155,7 +2267,7 @@ Aligned English subtitle const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio; - assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/); + assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/); assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined); }); }); @@ -2279,8 +2391,8 @@ Aligned English subtitle const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); const fields = updateRequest?.params?.note?.fields ?? {}; - assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/); - assert.match(fields.Picture ?? '', /^$/); + assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/); + assert.match(fields.Picture ?? '', /^$/); assert.equal(fields.ExpressionAudio, undefined); assert.equal(fields.SelectionText, undefined); }); @@ -2335,8 +2447,8 @@ Aligned English subtitle const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); const fields = updateRequest?.params?.note?.fields ?? {}; - assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/); - assert.match(fields.Picture ?? '', /^$/); + assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/); + assert.match(fields.Picture ?? '', /^$/); assert.equal(fields.ExpressionAudio, undefined); assert.equal(fields.SelectionText, undefined); }); @@ -2492,7 +2604,7 @@ Aligned English subtitle const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); assert.match( updateRequest?.params?.note?.fields?.Voice ?? '', - /^\[sound:subminer_audio_\d+\.mp3\]$/, + /^\[sound:subminer_audio_\d+_12345\.mp3\]$/, ); }, { notesInfoFields: null }, @@ -2580,7 +2692,11 @@ Aligned English subtitle const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio; - assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/); + assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/); + assert.match( + updateRequest?.params?.note?.fields?.Picture ?? '', + /^$/, + ); assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined); }); }); @@ -2670,6 +2786,26 @@ Aligned English subtitle assert.deepEqual(await res.json(), { ok: true }); }); + it('DELETE /api/stats/sessions rejects non-safe or fractional session ids', async () => { + let deleteCalls = 0; + const app = createStatsApp( + createMockTracker({ + deleteSessions: async () => { + deleteCalls += 1; + }, + }), + ); + + const res = await app.request('/api/stats/sessions', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: '{"sessionIds":[1.5,1e309,9007199254740992]}', + }); + + assert.equal(res.status, 400); + assert.equal(deleteCalls, 0); + }); + it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/anki/browse', { method: 'POST' }); @@ -2694,8 +2830,10 @@ Aligned English subtitle const originalFetch = globalThis.fetch; let acquireCalls = 0; let recordCalls = 0; - globalThis.fetch = (async () => - new Response( + let requestSignal: AbortSignal | null | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestSignal = init?.signal; + return new Response( JSON.stringify({ data: { Page: { @@ -2707,7 +2845,8 @@ Aligned English subtitle status: 200, headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' }, }, - )) as typeof fetch; + ); + }) as typeof fetch; try { const app = createStatsApp(createMockTracker(), { @@ -2725,6 +2864,7 @@ Aligned English subtitle assert.equal(res.status, 200); assert.equal(acquireCalls, 1); assert.equal(recordCalls, 1); + assert.ok(requestSignal instanceof AbortSignal); } finally { globalThis.fetch = originalFetch; } @@ -2923,6 +3063,15 @@ Aligned English subtitle }); }); + it('returns not found for malformed percent-encoded asset paths', async () => { + await withTempDir(async (dir) => { + const app = createStatsApp(createMockTracker(), { staticDir: dir }); + const res = await app.request('/assets/%'); + + assert.equal(res.status, 404); + }); + }); + it('fetches and serves missing cover art on demand', async () => { let ensureCalls = 0; let hasCover = false; diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index c12cc5e2..6254a20b 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -1,205 +1,28 @@ import { Hono } from 'hono'; -import type { ImmersionTrackerService } from './immersion-tracker-service.js'; -import { splitSentenceSearchTerms } from './immersion-tracker/query-lexical.js'; import http, { type IncomingMessage, type ServerResponse } from 'node:http'; -import { basename, extname, resolve, sep } from 'node:path'; -import { readFileSync, existsSync, statSync } from 'node:fs'; import { Readable } from 'node:stream'; -import { MediaGenerator } from '../../media-generator.js'; -import { AnkiConnectClient } from '../../anki-connect.js'; import type { AnkiConnectConfig } from '../../types.js'; -import { createLogger } from '../../logger.js'; -import { - getConfiguredSentenceFieldName, - getConfiguredTranslationFieldName, - getConfiguredWordFieldName, - getPreferredNoteFieldValue, -} from '../../anki-field-config.js'; -import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js'; import type { AnilistRateLimiter } from './anilist/rate-limiter.js'; -import { registerStatsCoverRoutes } from './stats-cover-routes.js'; +import type { ImmersionTrackerService } from './immersion-tracker-service.js'; +import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js'; +import type { StatsServerMediaGenerator } from './stats-server/mining-support.js'; import { - statsJson, - type StatsAnilistSearchResult, - type StatsAnkiBrowseResponse, -} from '../../types/stats-http-contract.js'; -import type { StatsExcludedWord } from '../../types/stats-wire.js'; -import { - resolveRetimedSecondarySubtitleTextFromSidecar, - resolveSecondarySubtitleTextFromSidecar, - type RetimedSecondarySubtitleInput, -} from './secondary-subtitle-sidecar.js'; + registerStatsAnalyticsRoutes, + registerStatsIntegrationRoutes, + registerStatsLibraryRoutes, + registerStatsMiningRoutes, + registerStatsStaticRoutes, +} from './stats-server/routes.js'; -type StatsServerNoteInfo = { - noteId: number; - fields: Record; -}; - -type StatsServerMediaGenerator = { - generateAudio: (...args: Parameters) => Promise; - generateScreenshot: ( - ...args: Parameters - ) => Promise; - generateAnimatedImage: ( - ...args: Parameters - ) => Promise; -}; - -export type StatsMiningTimingEvent = { - mode: 'word' | 'sentence' | 'audio'; - phase: string; - elapsedMs: number; - noteId?: number; -}; - -function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number { - if (raw === undefined) return fallback; - const n = Number(raw); - if (!Number.isFinite(n) || n < 0) { - return fallback; - } - const parsed = Math.floor(n); - return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); -} - -function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' { - return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all' - ? raw - : '30d'; -} - -function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' { - return raw === 'month' ? 'month' : 'day'; -} - -// Defaults to true (zero-fill empty calendar buckets); only an explicit -// "false" opts into the compact, data-only view. -function parseTrendFillEmpty(raw: string | undefined): boolean { - return raw !== 'false'; -} - -function parseEventTypesQuery(raw: string | undefined): number[] | undefined { - if (!raw) return undefined; - const parsed = raw - .split(',') - .map((entry) => Number.parseInt(entry.trim(), 10)) - .filter((entry) => Number.isInteger(entry) && entry > 0); - return parsed.length > 0 ? parsed : undefined; -} - -function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | null { - if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) { - return null; - } - - const words: StatsExcludedWord[] = []; - for (const row of (body as { words: unknown[] }).words) { - if (!row || typeof row !== 'object') return null; - const { headword, word, reading } = row as Record; - if (typeof headword !== 'string' || typeof word !== 'string' || typeof reading !== 'string') { - return null; - } - words.push({ headword, word, reading }); - } - return words; -} - -function resolveStatsNoteFieldName( - noteInfo: StatsServerNoteInfo, - ...preferredNames: (string | undefined)[] -): string | null { - for (const preferredName of preferredNames) { - if (!preferredName) continue; - const resolved = Object.keys(noteInfo.fields).find( - (fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(), - ); - if (resolved) return resolved; - } - return null; -} - -function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const fieldName of fieldNames) { - const normalized = fieldName?.trim(); - if (!normalized) continue; - const key = normalized.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - result.push(normalized); - } - return result; -} - -function getStatsWordMiningAudioFieldName( - ankiConfig: AnkiConnectConfig, - noteInfo: StatsServerNoteInfo | null, -): string { - return ( - (noteInfo - ? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio) - : null) ?? - ankiConfig.fields?.audio ?? - 'ExpressionAudio' - ); -} - -function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean { - return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true; -} - -function applyStatsWordAndSentenceCardFields( - fields: Record, - noteInfo: StatsServerNoteInfo | null, - ankiConfig: AnkiConnectConfig, -): void { - if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return; - - const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard'); - if (!wordAndSentenceFlag) return; - - fields[wordAndSentenceFlag] = 'x'; - for (const flagName of ['IsSentenceCard', 'IsAudioCard']) { - const resolved = resolveStatsNoteFieldName(noteInfo, flagName); - if (resolved && resolved !== wordAndSentenceFlag) { - fields[resolved] = ''; - } - } -} - -function getStatsDirectMiningAudioFieldNames( - ankiConfig: AnkiConnectConfig, - noteInfo: StatsServerNoteInfo | null, - mode: 'sentence' | 'audio', -): string[] { - const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio'; - if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) { - return [configuredAudioField]; - } - - const sentenceAudioField = noteInfo - ? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField) - : 'SentenceAudio'; - const expressionAudioField = noteInfo - ? resolveStatsNoteFieldName(noteInfo, configuredAudioField) - : configuredAudioField; - - if (mode === 'sentence') { - return uniqueFieldNames(sentenceAudioField); - } - - return uniqueFieldNames(sentenceAudioField, expressionAudioField); -} +export type { StatsMiningTimingEvent } from './stats-server/mining-support.js'; +import type { StatsMiningTimingEvent } from './stats-server/mining-support.js'; function toFetchHeaders(headers: IncomingMessage['headers']): Headers { const fetchHeaders = new Headers(); for (const [name, value] of Object.entries(headers)) { if (value === undefined) continue; if (Array.isArray(value)) { - for (const entry of value) { - fetchHeaders.append(name, entry); - } + for (const entry of value) fetchHeaders.append(name, entry); continue; } fetchHeaders.set(name, value); @@ -214,23 +37,17 @@ function toFetchRequest(req: IncomingMessage): Request { method, headers: toFetchHeaders(req.headers), }; - if (method !== 'GET' && method !== 'HEAD') { init.body = Readable.toWeb(req) as BodyInit; init.duplex = 'half'; } - return new Request(url, init); } async function writeFetchResponse(res: ServerResponse, response: Response): Promise { res.statusCode = response.status; - response.headers.forEach((value, key) => { - res.setHeader(key, value); - }); - - const body = await response.arrayBuffer(); - res.end(Buffer.from(body)); + response.headers.forEach((value, key) => res.setHeader(key, value)); + res.end(Buffer.from(await response.arrayBuffer())); } function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } { @@ -244,9 +61,7 @@ function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () } })(); }); - server.listen(config.port, '127.0.0.1'); - return { close: () => { server.close(); @@ -254,123 +69,6 @@ function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () }; } -/** Load known words cache from disk into a Set. Returns null if unavailable. */ -function loadKnownWordsSet(cachePath: string | undefined): Set | null { - if (!cachePath || !existsSync(cachePath)) return null; - try { - const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { - version?: number; - words?: string[]; - notes?: Record>; - }; - if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) { - return new Set(raw.words); - } - // v3 stores reading-aware entries per note; stats rows only carry - // headwords, so flatten to a word set (reading-agnostic, fail-open). - if (raw.version === 3 && raw.notes && typeof raw.notes === 'object') { - const words = new Set(); - for (const entries of Object.values(raw.notes)) { - if (!Array.isArray(entries)) continue; - for (const entry of entries) { - if (entry && typeof entry.word === 'string' && entry.word) { - words.add(entry.word); - } - } - } - return words; - } - } catch { - /* ignore */ - } - return null; -} - -/** Count how many headwords in the given list are in the known words set. */ -function countKnownWords( - headwords: string[], - knownWordsSet: Set, -): { totalUniqueWords: number; knownWordCount: number } { - let knownWordCount = 0; - for (const hw of headwords) { - if (knownWordsSet.has(hw)) knownWordCount++; - } - return { totalUniqueWords: headwords.length, knownWordCount }; -} - -function toKnownWordRate(knownWordsSeen: number, tokensSeen: number): number { - if (!Number.isFinite(knownWordsSeen) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) { - return 0; - } - return Number(((knownWordsSeen / tokensSeen) * 100).toFixed(1)); -} - -function summarizeFilteredWordOccurrences( - wordsByLine: Array<{ lineIndex: number; headword: string; occurrenceCount: number }>, - knownWordsSet: Set, -): { knownWordsSeen: number; totalWordsSeen: number } { - let knownWordsSeen = 0; - let totalWordsSeen = 0; - for (const row of wordsByLine) { - totalWordsSeen += row.occurrenceCount; - if (knownWordsSet.has(row.headword)) { - knownWordsSeen += row.occurrenceCount; - } - } - return { knownWordsSeen, totalWordsSeen }; -} - -async function enrichSessionsWithKnownWordMetrics< - Session extends { - sessionId: number; - tokensSeen: number; - }, ->( - tracker: ImmersionTrackerService, - sessions: Session[], - knownWordsCachePath?: string, -): Promise< - Array< - Session & { - knownWordsSeen: number; - knownWordRate: number; - } - > -> { - const knownWordsSet = loadKnownWordsSet(knownWordsCachePath); - if (!knownWordsSet) { - return sessions.map((session) => ({ - ...session, - knownWordsSeen: 0, - knownWordRate: 0, - })); - } - - const enriched = await Promise.all( - sessions.map(async (session) => { - let knownWordsSeen = 0; - let totalWordsSeen = 0; - try { - const wordsByLine = await tracker.getSessionWordsByLine(session.sessionId); - const summary = summarizeFilteredWordOccurrences(wordsByLine, knownWordsSet); - knownWordsSeen = summary.knownWordsSeen; - totalWordsSeen = summary.totalWordsSeen; - } catch { - knownWordsSeen = 0; - totalWordsSeen = 0; - } - - return { - ...session, - knownWordsSeen, - knownWordRate: toKnownWordRate(knownWordsSeen, totalWordsSeen), - }; - }), - ); - - return enriched; -} - export interface StatsServerConfig { port: number; staticDir: string; // Path to stats/dist/ @@ -393,124 +91,6 @@ export interface StatsServerConfig { resolveSentenceSearchHeadwords?: (term: string) => Promise | string[]; } -const STATS_STATIC_CONTENT_TYPES: Record = { - '.css': 'text/css; charset=utf-8', - '.gif': 'image/gif', - '.html': 'text/html; charset=utf-8', - '.ico': 'image/x-icon', - '.jpeg': 'image/jpeg', - '.jpg': 'image/jpeg', - '.js': 'text/javascript; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.txt': 'text/plain; charset=utf-8', - '.webp': 'image/webp', - '.woff': 'font/woff', - '.woff2': 'font/woff2', -}; -const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000; -const statsMiningLogger = createLogger('stats:mining'); - -function defaultNowMs(): number { - return Date.now(); -} - -function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean { - if (raw === undefined) return fallback; - const normalized = raw.trim().toLowerCase(); - if (!normalized) return fallback; - return !['0', 'false', 'no', 'off'].includes(normalized); -} - -function uniqueNonEmptyStrings(values: readonly string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const value of values) { - const normalized = value.trim(); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - result.push(normalized); - } - return result; -} - -async function buildSentenceSearchOptions( - query: string, - searchByHeadword: boolean, - resolveSentenceSearchHeadwords: ((term: string) => Promise | string[]) | undefined, -): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> { - if (!searchByHeadword) return undefined; - - const terms = splitSentenceSearchTerms(query); - const headwordTerms: Array<{ term: string; headwords: string[] }> = []; - for (const term of terms) { - const resolved = resolveSentenceSearchHeadwords - ? await resolveSentenceSearchHeadwords(term) - : [term]; - const headwords = uniqueNonEmptyStrings(resolved); - if (headwords.length > 0) { - headwordTerms.push({ term, headwords }); - } - } - - return headwordTerms.length > 0 ? { headwordTerms } : undefined; -} - -function buildAnkiNotePreview( - fields: Record, - ankiConfig?: Pick, -): { word: string; sentence: string; translation: string } { - return { - word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]), - sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]), - translation: getPreferredNoteFieldValue(fields, [ - getConfiguredTranslationFieldName(ankiConfig), - ]), - }; -} - -function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null { - const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html'; - const decodedPath = decodeURIComponent(normalizedPath); - const absoluteStaticDir = resolve(staticDir); - const absolutePath = resolve(absoluteStaticDir, decodedPath); - if ( - absolutePath !== absoluteStaticDir && - !absolutePath.startsWith(`${absoluteStaticDir}${sep}`) - ) { - return null; - } - if (!existsSync(absolutePath)) { - return null; - } - const stats = statSync(absolutePath); - if (!stats.isFile()) { - return null; - } - return absolutePath; -} - -function createStatsStaticResponse(staticDir: string, requestPath: string): Response | null { - const absolutePath = resolveStatsStaticPath(staticDir, requestPath); - if (!absolutePath) { - return null; - } - - const extension = extname(absolutePath).toLowerCase(); - const contentType = STATS_STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream'; - const body = readFileSync(absolutePath); - return new Response(body, { - headers: { - 'Content-Type': contentType, - 'Cache-Control': absolutePath.endsWith('index.html') - ? 'no-cache' - : 'public, max-age=31536000, immutable', - }, - }); -} - export function createStatsApp( tracker: ImmersionTrackerService, options?: { @@ -537,1000 +117,11 @@ export function createStatsApp( }, ) { const app = new Hono(); - const nowMs = options?.nowMs ?? defaultNowMs; - const getAnkiConnectConfig = (): AnkiConnectConfig | undefined => - options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig; - const getSecondarySubtitleLanguages = (): string[] => - options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? []; - const getStatsMiningAlassPath = (): string | null | undefined => - options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath; - const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise => { - const configuredDeckName = ankiConfig.deck?.trim() ?? ''; - if (configuredDeckName) return configuredDeckName; - - try { - const yomitanDeckName = await options?.getYomitanAnkiDeckName?.(); - return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : ''; - } catch (error) { - statsMiningLogger.warn( - 'Failed to resolve Yomitan Anki deck for stats mining:', - error instanceof Error ? error.message : String(error), - ); - return ''; - } - }; - - const recordMiningTiming = (event: StatsMiningTimingEvent): void => { - options?.onMiningTiming?.(event); - statsMiningLogger.debug( - `[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`, - event, - ); - }; - - const timeMiningPhase = async ( - mode: StatsMiningTimingEvent['mode'], - phase: string, - fn: () => Promise, - details?: (value: T) => Partial, - ): Promise => { - const startedAtMs = nowMs(); - try { - const value = await fn(); - recordMiningTiming({ - mode, - phase, - elapsedMs: nowMs() - startedAtMs, - ...details?.(value), - }); - return value; - } catch (err) { - recordMiningTiming({ - mode, - phase, - elapsedMs: nowMs() - startedAtMs, - }); - throw err; - } - }; - - app.get('/api/stats/overview', async (c) => { - const [rawSessions, rollups, hints] = await Promise.all([ - tracker.getSessionSummaries(5), - tracker.getDailyRollups(14), - tracker.getQueryHints(), - ]); - const sessions = await enrichSessionsWithKnownWordMetrics( - tracker, - rawSessions, - options?.knownWordCachePath, - ); - return c.json(statsJson('overview', { sessions, rollups, hints })); - }); - - app.get('/api/stats/daily-rollups', async (c) => { - const limit = parseIntQuery(c.req.query('limit'), 60, 500); - const rollups = await tracker.getDailyRollups(limit); - return c.json(statsJson('dailyRollups', rollups)); - }); - - app.get('/api/stats/monthly-rollups', async (c) => { - const limit = parseIntQuery(c.req.query('limit'), 24, 120); - const rollups = await tracker.getMonthlyRollups(limit); - return c.json(statsJson('monthlyRollups', rollups)); - }); - - app.get('/api/stats/streak-calendar', async (c) => { - const days = parseIntQuery(c.req.query('days'), 90, 365); - return c.json(statsJson('streakCalendar', await tracker.getStreakCalendar(days))); - }); - - app.get('/api/stats/trends/episodes-per-day', async (c) => { - const limit = parseIntQuery(c.req.query('limit'), 90, 365); - return c.json(statsJson('episodesPerDay', await tracker.getEpisodesPerDay(limit))); - }); - - app.get('/api/stats/trends/new-anime-per-day', async (c) => { - const limit = parseIntQuery(c.req.query('limit'), 90, 365); - return c.json(statsJson('newAnimePerDay', await tracker.getNewAnimePerDay(limit))); - }); - - app.get('/api/stats/trends/watch-time-per-anime', async (c) => { - const limit = parseIntQuery(c.req.query('limit'), 90, 365); - return c.json(statsJson('watchTimePerAnime', await tracker.getWatchTimePerAnime(limit))); - }); - - app.get('/api/stats/trends/dashboard', async (c) => { - const range = parseTrendRange(c.req.query('range')); - const groupBy = parseTrendGroupBy(c.req.query('groupBy')); - const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty')); - return c.json( - statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)), - ); - }); - - app.get('/api/stats/sessions', async (c) => { - const limit = parseIntQuery(c.req.query('limit'), 50, 500); - const rawSessions = await tracker.getSessionSummaries(limit); - const sessions = await enrichSessionsWithKnownWordMetrics( - tracker, - rawSessions, - options?.knownWordCachePath, - ); - return c.json(statsJson('sessions', sessions)); - }); - - app.get('/api/stats/sessions/:id/timeline', async (c) => { - const id = parseIntQuery(c.req.param('id'), 0); - if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400); - const rawLimit = c.req.query('limit'); - const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000); - const timeline = await tracker.getSessionTimeline(id, limit); - return c.json(statsJson('sessionTimeline', timeline)); - }); - - app.get('/api/stats/sessions/:id/events', async (c) => { - const id = parseIntQuery(c.req.param('id'), 0); - if (id <= 0) return c.json(statsJson('sessionEvents', []), 400); - const limit = parseIntQuery(c.req.query('limit'), 500, 1000); - const eventTypes = parseEventTypesQuery(c.req.query('types')); - const events = await tracker.getSessionEvents(id, limit, eventTypes); - return c.json(statsJson('sessionEvents', events)); - }); - - app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => { - const id = parseIntQuery(c.req.param('id'), 0); - if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400); - - const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set(); - - // Get per-line word occurrences for the session. - const wordsByLine = await tracker.getSessionWordsByLine(id); - - // Build cumulative filtered occurrence counts per recorded line index. - // The stats UI uses line-count progress to align this series with the session - // timeline, so preserve the stored line position rather than compressing gaps. - const totalLineGroups = new Map(); - const knownLineGroups = new Map(); - for (const row of wordsByLine) { - totalLineGroups.set( - row.lineIndex, - (totalLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount, - ); - if (knownWordsSet.has(row.headword)) { - knownLineGroups.set( - row.lineIndex, - (knownLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount, - ); - } - } - - const maxLineIndex = Math.max(...totalLineGroups.keys(), ...knownLineGroups.keys(), -1); - let knownWordsSeen = 0; - let totalWordsSeen = 0; - const knownByLinesSeen: Array<{ - linesSeen: number; - knownWordsSeen: number; - totalWordsSeen: number; - }> = []; - - for (let lineIdx = 0; lineIdx <= maxLineIndex; lineIdx += 1) { - knownWordsSeen += knownLineGroups.get(lineIdx) ?? 0; - totalWordsSeen += totalLineGroups.get(lineIdx) ?? 0; - knownByLinesSeen.push({ - linesSeen: lineIdx, - knownWordsSeen, - totalWordsSeen, - }); - } - - return c.json(statsJson('sessionKnownWordsTimeline', knownByLinesSeen)); - }); - - app.get('/api/stats/vocabulary', async (c) => { - const limit = parseIntQuery(c.req.query('limit'), 100, 500); - const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean); - const vocab = await tracker.getVocabularyStats(limit, excludePos); - return c.json(statsJson('vocabulary', vocab)); - }); - - app.get('/api/stats/excluded-words', async (c) => { - return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords())); - }); - - app.put('/api/stats/excluded-words', async (c) => { - const body = await c.req.json().catch(() => null); - const words = parseExcludedWordsBody(body); - if (!words) return c.body(null, 400); - await tracker.replaceStatsExcludedWords(words); - return c.json(statsJson('setExcludedWords', { ok: true })); - }); - - app.get('/api/stats/vocabulary/occurrences', async (c) => { - const headword = (c.req.query('headword') ?? '').trim(); - const word = (c.req.query('word') ?? '').trim(); - const reading = (c.req.query('reading') ?? '').trim(); - if (!headword || !word) { - return c.json(statsJson('wordOccurrences', []), 400); - } - const limit = parseIntQuery(c.req.query('limit'), 50, 500); - const offset = parseIntQuery(c.req.query('offset'), 0, 10_000); - const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset); - return c.json(statsJson('wordOccurrences', occurrences)); - }); - - app.get('/api/stats/sentences/search', async (c) => { - const query = (c.req.query('q') ?? '').trim(); - if (!query) return c.json(statsJson('sentenceSearch', [])); - const limit = parseIntQuery(c.req.query('limit'), 50, 100); - const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true); - const searchOptions = await buildSentenceSearchOptions( - query, - searchByHeadword, - options?.resolveSentenceSearchHeadwords, - ); - const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions); - return c.json(statsJson('sentenceSearch', rows)); - }); - - app.get('/api/stats/kanji', async (c) => { - const limit = parseIntQuery(c.req.query('limit'), 100, 500); - const kanji = await tracker.getKanjiStats(limit); - return c.json(statsJson('kanji', kanji)); - }); - - app.get('/api/stats/kanji/occurrences', async (c) => { - const kanji = (c.req.query('kanji') ?? '').trim(); - if (!kanji) { - return c.json(statsJson('kanjiOccurrences', []), 400); - } - const limit = parseIntQuery(c.req.query('limit'), 50, 500); - const offset = parseIntQuery(c.req.query('offset'), 0, 10_000); - const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset); - return c.json(statsJson('kanjiOccurrences', occurrences)); - }); - - app.get('/api/stats/vocabulary/:wordId/detail', async (c) => { - const wordId = parseIntQuery(c.req.param('wordId'), 0); - if (wordId <= 0) return c.body(null, 400); - const detail = await tracker.getWordDetail(wordId); - if (!detail) return c.body(null, 404); - const animeAppearances = await tracker.getWordAnimeAppearances(wordId); - const similarWords = await tracker.getSimilarWords(wordId); - return c.json(statsJson('wordDetail', { detail, animeAppearances, similarWords })); - }); - - app.get('/api/stats/kanji/:kanjiId/detail', async (c) => { - const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0); - if (kanjiId <= 0) return c.body(null, 400); - const detail = await tracker.getKanjiDetail(kanjiId); - if (!detail) return c.body(null, 404); - const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId); - const words = await tracker.getKanjiWords(kanjiId); - return c.json(statsJson('kanjiDetail', { detail, animeAppearances, words })); - }); - - app.get('/api/stats/media', async (c) => { - const library = await tracker.getMediaLibrary(); - return c.json(statsJson('mediaLibrary', library)); - }); - - app.get('/api/stats/media/:videoId', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.json(statsJson('error', null), 400); - const [detail, rawSessions, rollups] = await Promise.all([ - tracker.getMediaDetail(videoId), - tracker.getMediaSessions(videoId, 100), - tracker.getMediaDailyRollups(videoId, 90), - ]); - const sessions = await enrichSessionsWithKnownWordMetrics( - tracker, - rawSessions, - options?.knownWordCachePath, - ); - return c.json(statsJson('mediaDetail', { detail, sessions, rollups })); - }); - - app.get('/api/stats/anime', async (c) => { - const rows = await tracker.getAnimeLibrary(); - return c.json(statsJson('animeLibrary', rows)); - }); - - app.get('/api/stats/anime/:animeId', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); - const detail = await tracker.getAnimeDetail(animeId); - if (!detail) return c.body(null, 404); - const [episodes, anilistEntries] = await Promise.all([ - tracker.getAnimeEpisodes(animeId), - tracker.getAnimeAnilistEntries(animeId), - ]); - return c.json(statsJson('animeDetail', { detail, episodes, anilistEntries })); - }); - - app.get('/api/stats/anime/:animeId/words', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - const limit = parseIntQuery(c.req.query('limit'), 50, 200); - if (animeId <= 0) return c.body(null, 400); - return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit))); - }); - - app.get('/api/stats/anime/:animeId/rollups', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - const limit = parseIntQuery(c.req.query('limit'), 90, 365); - if (animeId <= 0) return c.body(null, 400); - return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit))); - }); - - app.patch('/api/stats/media/:videoId/watched', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); - const body = await c.req.json().catch(() => null); - const watched = typeof body?.watched === 'boolean' ? body.watched : true; - await tracker.setVideoWatched(videoId, watched); - return c.json(statsJson('setVideoWatched', { ok: true })); - }); - - app.delete('/api/stats/sessions', async (c) => { - const body = await c.req.json().catch(() => null); - const ids = Array.isArray(body?.sessionIds) - ? body.sessionIds.filter((id: unknown) => typeof id === 'number' && id > 0) - : []; - if (ids.length === 0) return c.body(null, 400); - await tracker.deleteSessions(ids); - return c.json(statsJson('deleteSessions', { ok: true })); - }); - - app.delete('/api/stats/sessions/:sessionId', async (c) => { - const sessionId = parseIntQuery(c.req.param('sessionId'), 0); - if (sessionId <= 0) return c.body(null, 400); - await tracker.deleteSession(sessionId); - return c.json(statsJson('deleteSession', { ok: true })); - }); - - app.delete('/api/stats/media/:videoId', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); - await tracker.deleteVideo(videoId); - return c.json(statsJson('deleteVideo', { ok: true })); - }); - - app.get('/api/stats/anilist/search', async (c) => { - const query = (c.req.query('q') ?? '').trim(); - if (!query) return c.json(statsJson('anilistSearch', [])); - try { - await options?.anilistRateLimiter?.acquire(); - const res = await fetch('https://graphql.anilist.co', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: `query ($search: String!) { - Page(perPage: 10) { - media(search: $search, type: ANIME) { - id - episodes - season - seasonYear - description(asHtml: false) - coverImage { large medium } - title { romaji english native } - } - } - }`, - variables: { search: query }, - }), - }); - options?.anilistRateLimiter?.recordResponse(res.headers); - if (res.status === 429) { - return c.json(statsJson('anilistSearch', [])); - } - const json = (await res.json()) as { - data?: { Page?: { media?: StatsAnilistSearchResult[] } }; - }; - return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? [])); - } catch { - return c.json(statsJson('anilistSearch', [])); - } - }); - - app.get('/api/stats/known-words', (c) => { - const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); - if (!knownWordsSet) return c.json(statsJson('knownWords', [])); - return c.json(statsJson('knownWords', [...knownWordsSet])); - }); - - app.get('/api/stats/known-words-summary', async (c) => { - const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); - if (!knownWordsSet) { - return c.json(statsJson('knownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 })); - } - const headwords = await tracker.getAllDistinctHeadwords(); - return c.json(statsJson('knownWordsSummary', countKnownWords(headwords, knownWordsSet))); - }); - - app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) { - return c.json( - statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), - 400, - ); - } - const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); - if (!knownWordsSet) { - return c.json( - statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), - ); - } - const headwords = await tracker.getAnimeDistinctHeadwords(animeId); - return c.json(statsJson('animeKnownWordsSummary', countKnownWords(headwords, knownWordsSet))); - }); - - app.get('/api/stats/media/:videoId/known-words-summary', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) { - return c.json( - statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), - 400, - ); - } - const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); - if (!knownWordsSet) { - return c.json( - statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), - ); - } - const headwords = await tracker.getMediaDistinctHeadwords(videoId); - return c.json(statsJson('mediaKnownWordsSummary', countKnownWords(headwords, knownWordsSet))); - }); - - app.patch('/api/stats/anime/:animeId/anilist', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); - const body = await c.req.json().catch(() => null); - if (!body?.anilistId) return c.body(null, 400); - await tracker.reassignAnimeAnilist(animeId, body); - return c.json(statsJson('reassignAnimeAnilist', { ok: true })); - }); - - registerStatsCoverRoutes(app, tracker); - - app.get('/api/stats/episode/:videoId/detail', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); - const rawSessions = await tracker.getEpisodeSessions(videoId); - const words = await tracker.getEpisodeWords(videoId); - const cardEvents = await tracker.getEpisodeCardEvents(videoId); - const sessions = await enrichSessionsWithKnownWordMetrics( - tracker, - rawSessions, - options?.knownWordCachePath, - ); - return c.json(statsJson('episodeDetail', { sessions, words, cardEvents })); - }); - - app.post('/api/stats/anki/browse', async (c) => { - const noteId = parseIntQuery(c.req.query('noteId'), 0); - if (noteId <= 0) return c.body(null, 400); - const ankiConfig = getAnkiConnectConfig(); - try { - const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), - body: JSON.stringify({ - action: 'guiBrowse', - version: 6, - params: { query: `nid:${noteId}` }, - }), - }); - const result = (await response.json()) as StatsAnkiBrowseResponse; - return c.json(statsJson('ankiBrowse', result)); - } catch { - return c.json(statsJson('error', { error: 'Failed to reach AnkiConnect' }), 502); - } - }); - - app.post('/api/stats/anki/notesInfo', async (c) => { - const body = await c.req.json().catch(() => null); - const noteIds: number[] = Array.isArray(body?.noteIds) - ? body.noteIds.filter( - (id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0, - ) - : []; - if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', [])); - const resolvedNoteIds = Array.from( - new Set( - noteIds.map((noteId) => { - const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId); - return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0 - ? (resolvedNoteId as number) - : noteId; - }), - ), - ); - try { - const ankiConfig = getAnkiConnectConfig(); - const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), - body: JSON.stringify({ - action: 'notesInfo', - version: 6, - params: { notes: resolvedNoteIds }, - }), - }); - const result = (await response.json()) as { - result?: Array<{ noteId: number; fields: Record }>; - }; - return c.json( - statsJson( - 'ankiNotesInfo', - (result.result ?? []).map((note) => ({ - ...note, - preview: buildAnkiNotePreview(note.fields, ankiConfig), - })), - ), - ); - } catch { - return c.json(statsJson('ankiNotesInfo', []), 502); - } - }); - - app.post('/api/stats/mine-card', async (c) => { - const body = await c.req.json().catch(() => null); - const sourcePath = typeof body?.sourcePath === 'string' ? body.sourcePath.trim() : ''; - const startMs = typeof body?.startMs === 'number' ? body.startMs : NaN; - const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN; - const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : ''; - const word = typeof body?.word === 'string' ? body.word.trim() : ''; - const bodySecondaryText = - typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : ''; - const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : ''; - const rawMode = c.req.query('mode'); - const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence'; - - if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) { - return c.json( - statsJson('mineCard', { - error: 'sourcePath, sentence, startMs, and endMs are required', - }), - 400, - ); - } - if (endMs <= startMs) { - return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400); - } - - if (!existsSync(sourcePath)) { - return c.json(statsJson('mineCard', { error: 'File not found' }), 404); - } - - const ankiConfig = getAnkiConnectConfig(); - if (!ankiConfig) { - return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500); - } - const secondarySubtitleLanguages = getSecondarySubtitleLanguages(); - let retimedSecondaryText = ''; - if (mode === 'sentence' && !bodySecondaryText) { - try { - retimedSecondaryText = await ( - options?.resolveRetimedSecondarySubtitleText ?? - resolveRetimedSecondarySubtitleTextFromSidecar - )({ - sourcePath, - startMs, - endMs, - languages: secondarySubtitleLanguages, - alassPath: getStatsMiningAlassPath(), - }); - } catch (error) { - statsMiningLogger.warn( - 'Failed to resolve retimed secondary subtitle for stats mining:', - error instanceof Error ? error.message : String(error), - ); - } - } - const secondaryText = - bodySecondaryText || - retimedSecondaryText || - resolveSecondarySubtitleTextFromSidecar({ - sourcePath, - startMs, - endMs, - languages: secondarySubtitleLanguages, - }); - - const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765'); - const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator(); - - const audioPadding = ankiConfig.media?.audioPadding ?? 0; - const normalizeAudio = ankiConfig.media?.normalizeAudio !== false; - const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30; - - const startSec = startMs / 1000; - const endSec = endMs / 1000; - const rawDuration = endSec - startSec; - const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec; - - const highlightedSentence = word - ? sentence.replace( - new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), - `${word}`, - ) - : sentence; - - const generateAudio = ankiConfig.media?.generateAudio !== false; - const generateImage = ankiConfig.media?.generateImage !== false && mode !== 'audio'; - const imageType = ankiConfig.media?.imageType ?? 'static'; - const syncAnimatedImageToWordAudio = - imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false; - - const audioPromise = generateAudio - ? timeMiningPhase(mode, 'generateAudio', () => - mediaGen.generateAudio( - sourcePath, - startSec, - clampedEndSec, - audioPadding, - null, - normalizeAudio, - ), - ) - : Promise.resolve(null); - - const createImagePromise = (animatedLeadInSeconds = 0): Promise => { - if (!generateImage) { - return Promise.resolve(null); - } - - if (imageType === 'avif') { - return timeMiningPhase(mode, 'generateAnimatedImage', () => - mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, { - fps: ankiConfig.media?.animatedFps ?? 10, - maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, - maxHeight: ankiConfig.media?.animatedMaxHeight, - crf: ankiConfig.media?.animatedCrf ?? 35, - leadingStillDuration: animatedLeadInSeconds, - }), - ); - } - - const midpointSec = (startSec + clampedEndSec) / 2; - return timeMiningPhase(mode, 'generateScreenshot', () => - mediaGen.generateScreenshot(sourcePath, midpointSec, { - format: ankiConfig.media?.imageFormat ?? 'jpg', - quality: ankiConfig.media?.imageQuality ?? 92, - maxWidth: ankiConfig.media?.imageMaxWidth, - maxHeight: ankiConfig.media?.imageMaxHeight, - }), - ); - }; - - const imagePromise = - mode === 'word' && syncAnimatedImageToWordAudio - ? Promise.resolve(null) - : createImagePromise(); - - const errors: string[] = []; - let noteId: number; - let effectiveDeckNamePromise: Promise | null = null; - const getEffectiveDeckNameForRequest = (): Promise => { - effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig); - return effectiveDeckNamePromise; - }; - const moveNoteToConfiguredDeck = async (id: number): Promise => { - const deckName = await getEffectiveDeckNameForRequest(); - if (!deckName) { - return; - } - try { - const cardIds = await timeMiningPhase(mode, 'findCards', () => - client.findCards(`nid:${id}`), - ); - await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName)); - } catch (err) { - errors.push(`deck: ${(err as Error).message}`); - } - }; - - if (mode === 'word') { - if (!options?.addYomitanNote) { - return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500); - } - - const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([ - timeMiningPhase( - 'word', - 'addYomitanNote', - () => options.addYomitanNote!(word), - (noteId) => (typeof noteId === 'number' ? { noteId } : {}), - ), - audioPromise, - imagePromise, - ]); - - if (yomitanResult.status === 'rejected' || !yomitanResult.value) { - return c.json( - statsJson('mineCard', { - error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`, - }), - 502, - ); - } - - noteId = yomitanResult.value; - await moveNoteToConfiguredDeck(noteId); - const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; - if (audioResult.status === 'rejected') - errors.push(`audio: ${(audioResult.reason as Error).message}`); - if (imageResult.status === 'rejected') - errors.push(`image: ${(imageResult.reason as Error).message}`); - - let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; - let noteInfo: StatsServerNoteInfo | null = null; - if ( - audioBuffer || - (syncAnimatedImageToWordAudio && generateImage) || - shouldUseStatsLapisKikuCardFields(ankiConfig) - ) { - try { - const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; - noteInfo = noteInfoResult[0] ?? null; - } catch (err) { - if (syncAnimatedImageToWordAudio && generateImage) { - errors.push(`image: ${(err as Error).message}`); - } - } - } - if (syncAnimatedImageToWordAudio && generateImage) { - try { - const animatedLeadInSeconds = noteInfo - ? await resolveAnimatedImageLeadInSeconds({ - config: ankiConfig, - noteInfo, - resolveConfiguredFieldName: (candidateNoteInfo, ...preferredNames) => - resolveStatsNoteFieldName(candidateNoteInfo, ...preferredNames), - retrieveMediaFileBase64: (filename) => client.retrieveMediaFile(filename), - }) - : 0; - imageBuffer = await createImagePromise(animatedLeadInSeconds); - } catch (err) { - errors.push(`image: ${(err as Error).message}`); - } - } - if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') { - errors.push('audio: no audio generated'); - } - if (generateImage && !imageBuffer) { - errors.push('image: no image generated'); - } - - const mediaFields: Record = {}; - const timestamp = Date.now(); - const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; - const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo); - const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; - - mediaFields[sentenceFieldName] = highlightedSentence; - applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig); - - if (audioBuffer) { - const audioFilename = `subminer_audio_${timestamp}.mp3`; - try { - await timeMiningPhase('word', 'uploadAudio', () => - client.storeMediaFile(audioFilename, audioBuffer), - ); - mediaFields[audioFieldName] = `[sound:${audioFilename}]`; - } catch (err) { - errors.push(`audio upload: ${(err as Error).message}`); - } - } - - if (imageBuffer) { - const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg'); - const imageFilename = `subminer_image_${timestamp}.${imageExt}`; - try { - await timeMiningPhase('word', 'uploadImage', () => - client.storeMediaFile(imageFilename, imageBuffer), - ); - mediaFields[imageFieldName] = ``; - } catch (err) { - errors.push(`image upload: ${(err as Error).message}`); - } - } - - const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? ''; - if (miscInfoFieldName) { - const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)'; - const filenameWithExt = videoTitle || basename(sourcePath); - const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); - const totalMs = Math.floor(startMs); - const totalSec2 = Math.floor(totalMs / 1000); - const hours = String(Math.floor(totalSec2 / 3600)).padStart(2, '0'); - const minutes = String(Math.floor((totalSec2 % 3600) / 60)).padStart(2, '0'); - const secs = String(totalSec2 % 60).padStart(2, '0'); - const ms = String(totalMs % 1000).padStart(3, '0'); - mediaFields[miscInfoFieldName] = pattern - .replace(/%f/g, filenameWithoutExt) - .replace(/%F/g, filenameWithExt) - .replace(/%t/g, `${hours}:${minutes}:${secs}`) - .replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`) - .replace(/
/g, '\n'); - } - - if (Object.keys(mediaFields).length > 0) { - try { - await timeMiningPhase('word', 'updateNoteFields', () => - client.updateNoteFields(noteId, mediaFields), - ); - } catch (err) { - errors.push(`update fields: ${(err as Error).message}`); - } - } - - return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) })); - } - - const wordFieldName = getConfiguredWordFieldName(ankiConfig); - const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; - const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; - const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; - const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? ''; - - const fields: Record = { - [sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence, - }; - - if (mode === 'sentence' && secondaryText) { - fields[translationFieldName] = secondaryText; - } - - if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) { - if (mode === 'sentence') { - fields[wordFieldName] = sentence; - } else if (word) { - fields[wordFieldName] = word; - } - if (mode === 'sentence') { - fields['IsSentenceCard'] = 'x'; - } else if (mode === 'audio') { - fields['IsAudioCard'] = 'x'; - } - } - - const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic'; - const tags = ankiConfig.tags ?? ['SubMiner']; - - const addNotePromise = timeMiningPhase( - mode, - 'addNote', - async () => - client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags), - (id) => ({ - noteId: id, - }), - ); - - const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([ - audioPromise, - imagePromise, - addNotePromise, - ]); - - const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; - const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; - if (audioResult.status === 'rejected') - errors.push(`audio: ${(audioResult.reason as Error).message}`); - if (imageResult.status === 'rejected') - errors.push(`image: ${(imageResult.reason as Error).message}`); - - if (addNoteResult.status === 'rejected') { - return c.json( - statsJson('mineCard', { - error: `Failed to add note: ${(addNoteResult.reason as Error).message}`, - }), - 502, - ); - } - noteId = addNoteResult.value; - await moveNoteToConfiguredDeck(noteId); - - const mediaFields: Record = {}; - const timestamp = Date.now(); - let noteInfo: StatsServerNoteInfo | null = null; - if (audioBuffer) { - try { - const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; - noteInfo = noteInfoResult[0] ?? null; - } catch { - noteInfo = null; - } - } - - if (audioBuffer) { - const audioFilename = `subminer_audio_${timestamp}.mp3`; - try { - await timeMiningPhase(mode, 'uploadAudio', () => - client.storeMediaFile(audioFilename, audioBuffer), - ); - const audioValue = `[sound:${audioFilename}]`; - for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) { - mediaFields[fieldName] = audioValue; - } - } catch (err) { - errors.push(`audio upload: ${(err as Error).message}`); - } - } - - if (imageBuffer) { - const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg'); - const imageFilename = `subminer_image_${timestamp}.${imageExt}`; - try { - await timeMiningPhase(mode, 'uploadImage', () => - client.storeMediaFile(imageFilename, imageBuffer), - ); - mediaFields[imageFieldName] = ``; - } catch (err) { - errors.push(`image upload: ${(err as Error).message}`); - } - } - - if (miscInfoFieldName) { - const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)'; - const filenameWithExt = videoTitle || basename(sourcePath); - const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); - const totalMs = Math.floor(startMs); - const totalSec = Math.floor(totalMs / 1000); - const hours = String(Math.floor(totalSec / 3600)).padStart(2, '0'); - const minutes = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0'); - const secs = String(totalSec % 60).padStart(2, '0'); - const ms = String(totalMs % 1000).padStart(3, '0'); - const miscInfo = pattern - .replace(/%f/g, filenameWithoutExt) - .replace(/%F/g, filenameWithExt) - .replace(/%t/g, `${hours}:${minutes}:${secs}`) - .replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`) - .replace(/
/g, '\n'); - mediaFields[miscInfoFieldName] = miscInfo; - } - - if (Object.keys(mediaFields).length > 0) { - try { - await timeMiningPhase(mode, 'updateNoteFields', () => - client.updateNoteFields(noteId, mediaFields), - ); - } catch (err) { - errors.push(`update fields: ${(err as Error).message}`); - } - } - - return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) })); - }); - - if (options?.staticDir) { - app.get('/assets/*', (c) => { - const response = createStatsStaticResponse(options.staticDir!, c.req.path); - if (!response) return c.text('Not found', 404); - return response; - }); - - app.get('/index.html', (c) => { - const response = createStatsStaticResponse(options.staticDir!, '/index.html'); - if (!response) return c.text('Stats UI not built', 404); - return response; - }); - - app.get('*', (c) => { - const staticResponse = createStatsStaticResponse(options.staticDir!, c.req.path); - if (staticResponse) return staticResponse; - const fallback = createStatsStaticResponse(options.staticDir!, '/index.html'); - if (!fallback) return c.text('Stats UI not built', 404); - return fallback; - }); - } - + registerStatsAnalyticsRoutes(app, tracker, options); + registerStatsLibraryRoutes(app, tracker, options); + registerStatsIntegrationRoutes(app, tracker, options); + registerStatsMiningRoutes(app, options); + registerStatsStaticRoutes(app, options?.staticDir); return app; } @@ -1560,20 +151,13 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void }; }; }; - if (bunRuntime.Bun?.serve) { const server = bunRuntime.Bun.serve({ fetch: app.fetch, port: config.port, hostname: '127.0.0.1', }); - - return { - close: () => { - server.stop(); - }, - }; + return { close: () => server.stop() }; } - return startNodeHttpServer(app, config); } diff --git a/src/core/services/stats-server/analytics-routes.ts b/src/core/services/stats-server/analytics-routes.ts new file mode 100644 index 00000000..6a9f3e6e --- /dev/null +++ b/src/core/services/stats-server/analytics-routes.ts @@ -0,0 +1,151 @@ +import type { Hono } from 'hono'; +import { statsJson } from '../../../types/stats-http-contract.js'; +import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; +import { + enrichSessionsWithKnownWordMetrics, + loadKnownWordsSet, + parseEventTypesQuery, + parseIntQuery, + parseTrendFillEmpty, + parseTrendGroupBy, + parseTrendRange, +} from './route-support.js'; + +export function registerStatsAnalyticsRoutes( + app: Hono, + tracker: ImmersionTrackerService, + options?: { knownWordCachePath?: string }, +): void { + app.get('/api/stats/overview', async (c) => { + const [rawSessions, rollups, hints] = await Promise.all([ + tracker.getSessionSummaries(5), + tracker.getDailyRollups(14), + tracker.getQueryHints(), + ]); + const sessions = await enrichSessionsWithKnownWordMetrics( + tracker, + rawSessions, + options?.knownWordCachePath, + ); + return c.json(statsJson('overview', { sessions, rollups, hints })); + }); + + app.get('/api/stats/daily-rollups', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 60, 500); + const rollups = await tracker.getDailyRollups(limit); + return c.json(statsJson('dailyRollups', rollups)); + }); + + app.get('/api/stats/monthly-rollups', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 24, 120); + const rollups = await tracker.getMonthlyRollups(limit); + return c.json(statsJson('monthlyRollups', rollups)); + }); + + app.get('/api/stats/streak-calendar', async (c) => { + const days = parseIntQuery(c.req.query('days'), 90, 365); + return c.json(statsJson('streakCalendar', await tracker.getStreakCalendar(days))); + }); + + app.get('/api/stats/trends/episodes-per-day', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 90, 365); + return c.json(statsJson('episodesPerDay', await tracker.getEpisodesPerDay(limit))); + }); + + app.get('/api/stats/trends/new-anime-per-day', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 90, 365); + return c.json(statsJson('newAnimePerDay', await tracker.getNewAnimePerDay(limit))); + }); + + app.get('/api/stats/trends/watch-time-per-anime', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 90, 365); + return c.json(statsJson('watchTimePerAnime', await tracker.getWatchTimePerAnime(limit))); + }); + + app.get('/api/stats/trends/dashboard', async (c) => { + const range = parseTrendRange(c.req.query('range')); + const groupBy = parseTrendGroupBy(c.req.query('groupBy')); + const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty')); + return c.json( + statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)), + ); + }); + + app.get('/api/stats/sessions', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 50, 500); + const rawSessions = await tracker.getSessionSummaries(limit); + const sessions = await enrichSessionsWithKnownWordMetrics( + tracker, + rawSessions, + options?.knownWordCachePath, + ); + return c.json(statsJson('sessions', sessions)); + }); + + app.get('/api/stats/sessions/:id/timeline', async (c) => { + const id = parseIntQuery(c.req.param('id'), 0); + if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400); + const rawLimit = c.req.query('limit'); + const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000); + const timeline = await tracker.getSessionTimeline(id, limit); + return c.json(statsJson('sessionTimeline', timeline)); + }); + + app.get('/api/stats/sessions/:id/events', async (c) => { + const id = parseIntQuery(c.req.param('id'), 0); + if (id <= 0) return c.json(statsJson('sessionEvents', []), 400); + const limit = parseIntQuery(c.req.query('limit'), 500, 1000); + const eventTypes = parseEventTypesQuery(c.req.query('types')); + const events = await tracker.getSessionEvents(id, limit, eventTypes); + return c.json(statsJson('sessionEvents', events)); + }); + + app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => { + const id = parseIntQuery(c.req.param('id'), 0); + if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400); + + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set(); + + // Get per-line word occurrences for the session. + const wordsByLine = await tracker.getSessionWordsByLine(id); + + // Build cumulative filtered occurrence counts per recorded line index. + // The stats UI uses line-count progress to align this series with the session + // timeline, so preserve the stored line position rather than compressing gaps. + const totalLineGroups = new Map(); + const knownLineGroups = new Map(); + for (const row of wordsByLine) { + totalLineGroups.set( + row.lineIndex, + (totalLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount, + ); + if (knownWordsSet.has(row.headword)) { + knownLineGroups.set( + row.lineIndex, + (knownLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount, + ); + } + } + + const maxLineIndex = Math.max(...totalLineGroups.keys(), ...knownLineGroups.keys(), -1); + let knownWordsSeen = 0; + let totalWordsSeen = 0; + const knownByLinesSeen: Array<{ + linesSeen: number; + knownWordsSeen: number; + totalWordsSeen: number; + }> = []; + + for (let lineIdx = 0; lineIdx <= maxLineIndex; lineIdx += 1) { + knownWordsSeen += knownLineGroups.get(lineIdx) ?? 0; + totalWordsSeen += totalLineGroups.get(lineIdx) ?? 0; + knownByLinesSeen.push({ + linesSeen: lineIdx, + knownWordsSeen, + totalWordsSeen, + }); + } + + return c.json(statsJson('sessionKnownWordsTimeline', knownByLinesSeen)); + }); +} diff --git a/src/core/services/stats-server/integration-routes.ts b/src/core/services/stats-server/integration-routes.ts new file mode 100644 index 00000000..cdc26510 --- /dev/null +++ b/src/core/services/stats-server/integration-routes.ts @@ -0,0 +1,224 @@ +import type { Hono } from 'hono'; +import type { AnkiConnectConfig } from '../../../types.js'; +import { + statsJson, + type StatsAnilistSearchResult, + type StatsAnkiBrowseResponse, +} from '../../../types/stats-http-contract.js'; +import type { AnilistRateLimiter } from '../anilist/rate-limiter.js'; +import { registerStatsCoverRoutes } from '../stats-cover-routes.js'; +import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; +import { + buildAnkiNotePreview, + countKnownWords, + enrichSessionsWithKnownWordMetrics, + loadKnownWordsSet, + parseIntQuery, +} from './route-support.js'; + +const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000; +const ANILIST_FETCH_TIMEOUT_MS = 3_000; + +export function registerStatsIntegrationRoutes( + app: Hono, + tracker: ImmersionTrackerService, + options?: { + knownWordCachePath?: string; + ankiConnectConfig?: AnkiConnectConfig; + getAnkiConnectConfig?: () => AnkiConnectConfig | undefined; + anilistRateLimiter?: AnilistRateLimiter; + resolveAnkiNoteId?: (noteId: number) => number; + }, +): void { + const getAnkiConnectConfig = (): AnkiConnectConfig | undefined => + options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig; + app.get('/api/stats/anilist/search', async (c) => { + const query = (c.req.query('q') ?? '').trim(); + if (!query) return c.json(statsJson('anilistSearch', [])); + try { + await options?.anilistRateLimiter?.acquire(); + const res = await fetch('https://graphql.anilist.co', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(ANILIST_FETCH_TIMEOUT_MS), + body: JSON.stringify({ + query: `query ($search: String!) { + Page(perPage: 10) { + media(search: $search, type: ANIME) { + id + episodes + season + seasonYear + description(asHtml: false) + coverImage { large medium } + title { romaji english native } + } + } + }`, + variables: { search: query }, + }), + }); + options?.anilistRateLimiter?.recordResponse(res.headers); + if (res.status === 429) { + return c.json(statsJson('anilistSearch', [])); + } + const json = (await res.json()) as { + data?: { Page?: { media?: StatsAnilistSearchResult[] } }; + }; + return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? [])); + } catch { + return c.json(statsJson('anilistSearch', [])); + } + }); + + app.get('/api/stats/known-words', (c) => { + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); + if (!knownWordsSet) return c.json(statsJson('knownWords', [])); + return c.json(statsJson('knownWords', [...knownWordsSet])); + }); + + app.get('/api/stats/known-words-summary', async (c) => { + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); + if (!knownWordsSet) { + return c.json(statsJson('knownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 })); + } + const headwords = await tracker.getAllDistinctHeadwords(); + return c.json(statsJson('knownWordsSummary', countKnownWords(headwords, knownWordsSet))); + }); + + app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + if (animeId <= 0) { + return c.json( + statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), + 400, + ); + } + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); + if (!knownWordsSet) { + return c.json( + statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), + ); + } + const headwords = await tracker.getAnimeDistinctHeadwords(animeId); + return c.json(statsJson('animeKnownWordsSummary', countKnownWords(headwords, knownWordsSet))); + }); + + app.get('/api/stats/media/:videoId/known-words-summary', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) { + return c.json( + statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), + 400, + ); + } + const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); + if (!knownWordsSet) { + return c.json( + statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), + ); + } + const headwords = await tracker.getMediaDistinctHeadwords(videoId); + return c.json(statsJson('mediaKnownWordsSummary', countKnownWords(headwords, knownWordsSet))); + }); + + app.patch('/api/stats/anime/:animeId/anilist', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + if (animeId <= 0) return c.body(null, 400); + const body = await c.req.json().catch(() => null); + if ( + typeof body?.anilistId !== 'number' || + !Number.isInteger(body.anilistId) || + body.anilistId <= 0 + ) { + return c.body(null, 400); + } + await tracker.reassignAnimeAnilist(animeId, body); + return c.json(statsJson('reassignAnimeAnilist', { ok: true })); + }); + + registerStatsCoverRoutes(app, tracker); + + app.get('/api/stats/episode/:videoId/detail', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.body(null, 400); + const rawSessions = await tracker.getEpisodeSessions(videoId); + const words = await tracker.getEpisodeWords(videoId); + const cardEvents = await tracker.getEpisodeCardEvents(videoId); + const sessions = await enrichSessionsWithKnownWordMetrics( + tracker, + rawSessions, + options?.knownWordCachePath, + ); + return c.json(statsJson('episodeDetail', { sessions, words, cardEvents })); + }); + + app.post('/api/stats/anki/browse', async (c) => { + const noteId = parseIntQuery(c.req.query('noteId'), 0); + if (noteId <= 0) return c.body(null, 400); + const ankiConfig = getAnkiConnectConfig(); + try { + const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), + body: JSON.stringify({ + action: 'guiBrowse', + version: 6, + params: { query: `nid:${noteId}` }, + }), + }); + const result = (await response.json()) as StatsAnkiBrowseResponse; + return c.json(statsJson('ankiBrowse', result)); + } catch { + return c.json(statsJson('error', { error: 'Failed to reach AnkiConnect' }), 502); + } + }); + + app.post('/api/stats/anki/notesInfo', async (c) => { + const body = await c.req.json().catch(() => null); + const noteIds: number[] = Array.isArray(body?.noteIds) + ? body.noteIds.filter( + (id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0, + ) + : []; + if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', [])); + const resolvedNoteIds = Array.from( + new Set( + noteIds.map((noteId) => { + const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId); + return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0 + ? (resolvedNoteId as number) + : noteId; + }), + ), + ); + try { + const ankiConfig = getAnkiConnectConfig(); + const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), + body: JSON.stringify({ + action: 'notesInfo', + version: 6, + params: { notes: resolvedNoteIds }, + }), + }); + const result = (await response.json()) as { + result?: Array<{ noteId: number; fields: Record }>; + }; + return c.json( + statsJson( + 'ankiNotesInfo', + (result.result ?? []).map((note) => ({ + ...note, + preview: buildAnkiNotePreview(note.fields, ankiConfig), + })), + ), + ); + } catch { + return c.json(statsJson('ankiNotesInfo', []), 502); + } + }); +} diff --git a/src/core/services/stats-server/library-routes.ts b/src/core/services/stats-server/library-routes.ts new file mode 100644 index 00000000..18dfeada --- /dev/null +++ b/src/core/services/stats-server/library-routes.ts @@ -0,0 +1,193 @@ +import type { Hono } from 'hono'; +import { statsJson } from '../../../types/stats-http-contract.js'; +import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; +import { + buildSentenceSearchOptions, + enrichSessionsWithKnownWordMetrics, + parseBooleanQuery, + parseExcludedWordsBody, + parseIntQuery, +} from './route-support.js'; + +export function registerStatsLibraryRoutes( + app: Hono, + tracker: ImmersionTrackerService, + options?: { + knownWordCachePath?: string; + resolveSentenceSearchHeadwords?: (term: string) => Promise | string[]; + }, +): void { + app.get('/api/stats/vocabulary', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 100, 500); + const excludePos = c.req + .query('excludePos') + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + const vocab = await tracker.getVocabularyStats(limit, excludePos); + return c.json(statsJson('vocabulary', vocab)); + }); + + app.get('/api/stats/excluded-words', async (c) => { + return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords())); + }); + + app.put('/api/stats/excluded-words', async (c) => { + const body = await c.req.json().catch(() => null); + const words = parseExcludedWordsBody(body); + if (!words) return c.body(null, 400); + await tracker.replaceStatsExcludedWords(words); + return c.json(statsJson('setExcludedWords', { ok: true })); + }); + + app.get('/api/stats/vocabulary/occurrences', async (c) => { + const headword = (c.req.query('headword') ?? '').trim(); + const word = (c.req.query('word') ?? '').trim(); + const reading = (c.req.query('reading') ?? '').trim(); + if (!headword || !word) { + return c.json(statsJson('wordOccurrences', []), 400); + } + const limit = parseIntQuery(c.req.query('limit'), 50, 500); + const offset = parseIntQuery(c.req.query('offset'), 0, 10_000); + const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset); + return c.json(statsJson('wordOccurrences', occurrences)); + }); + + app.get('/api/stats/sentences/search', async (c) => { + const query = (c.req.query('q') ?? '').trim(); + if (!query) return c.json(statsJson('sentenceSearch', [])); + const limit = parseIntQuery(c.req.query('limit'), 50, 100); + const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true); + const searchOptions = await buildSentenceSearchOptions( + query, + searchByHeadword, + options?.resolveSentenceSearchHeadwords, + ); + const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions); + return c.json(statsJson('sentenceSearch', rows)); + }); + + app.get('/api/stats/kanji', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 100, 500); + const kanji = await tracker.getKanjiStats(limit); + return c.json(statsJson('kanji', kanji)); + }); + + app.get('/api/stats/kanji/occurrences', async (c) => { + const kanji = (c.req.query('kanji') ?? '').trim(); + if (!kanji) { + return c.json(statsJson('kanjiOccurrences', []), 400); + } + const limit = parseIntQuery(c.req.query('limit'), 50, 500); + const offset = parseIntQuery(c.req.query('offset'), 0, 10_000); + const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset); + return c.json(statsJson('kanjiOccurrences', occurrences)); + }); + + app.get('/api/stats/vocabulary/:wordId/detail', async (c) => { + const wordId = parseIntQuery(c.req.param('wordId'), 0); + if (wordId <= 0) return c.body(null, 400); + const detail = await tracker.getWordDetail(wordId); + if (!detail) return c.body(null, 404); + const animeAppearances = await tracker.getWordAnimeAppearances(wordId); + const similarWords = await tracker.getSimilarWords(wordId); + return c.json(statsJson('wordDetail', { detail, animeAppearances, similarWords })); + }); + + app.get('/api/stats/kanji/:kanjiId/detail', async (c) => { + const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0); + if (kanjiId <= 0) return c.body(null, 400); + const detail = await tracker.getKanjiDetail(kanjiId); + if (!detail) return c.body(null, 404); + const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId); + const words = await tracker.getKanjiWords(kanjiId); + return c.json(statsJson('kanjiDetail', { detail, animeAppearances, words })); + }); + + app.get('/api/stats/media', async (c) => { + const library = await tracker.getMediaLibrary(); + return c.json(statsJson('mediaLibrary', library)); + }); + + app.get('/api/stats/media/:videoId', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.json(statsJson('error', null), 400); + const [detail, rawSessions, rollups] = await Promise.all([ + tracker.getMediaDetail(videoId), + tracker.getMediaSessions(videoId, 100), + tracker.getMediaDailyRollups(videoId, 90), + ]); + const sessions = await enrichSessionsWithKnownWordMetrics( + tracker, + rawSessions, + options?.knownWordCachePath, + ); + return c.json(statsJson('mediaDetail', { detail, sessions, rollups })); + }); + + app.get('/api/stats/anime', async (c) => { + const rows = await tracker.getAnimeLibrary(); + return c.json(statsJson('animeLibrary', rows)); + }); + + app.get('/api/stats/anime/:animeId', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + if (animeId <= 0) return c.body(null, 400); + const detail = await tracker.getAnimeDetail(animeId); + if (!detail) return c.body(null, 404); + const [episodes, anilistEntries] = await Promise.all([ + tracker.getAnimeEpisodes(animeId), + tracker.getAnimeAnilistEntries(animeId), + ]); + return c.json(statsJson('animeDetail', { detail, episodes, anilistEntries })); + }); + + app.get('/api/stats/anime/:animeId/words', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + const limit = parseIntQuery(c.req.query('limit'), 50, 200); + if (animeId <= 0) return c.body(null, 400); + return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit))); + }); + + app.get('/api/stats/anime/:animeId/rollups', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + const limit = parseIntQuery(c.req.query('limit'), 90, 365); + if (animeId <= 0) return c.body(null, 400); + return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit))); + }); + + app.patch('/api/stats/media/:videoId/watched', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.body(null, 400); + const body = await c.req.json().catch(() => null); + const watched = typeof body?.watched === 'boolean' ? body.watched : true; + await tracker.setVideoWatched(videoId, watched); + return c.json(statsJson('setVideoWatched', { ok: true })); + }); + + app.delete('/api/stats/sessions', async (c) => { + const body = await c.req.json().catch(() => null); + const ids = Array.isArray(body?.sessionIds) + ? body.sessionIds.filter( + (id: unknown): id is number => Number.isSafeInteger(id) && (id as number) > 0, + ) + : []; + if (ids.length === 0) return c.body(null, 400); + await tracker.deleteSessions(ids); + return c.json(statsJson('deleteSessions', { ok: true })); + }); + + app.delete('/api/stats/sessions/:sessionId', async (c) => { + const sessionId = parseIntQuery(c.req.param('sessionId'), 0); + if (sessionId <= 0) return c.body(null, 400); + await tracker.deleteSession(sessionId); + return c.json(statsJson('deleteSession', { ok: true })); + }); + + app.delete('/api/stats/media/:videoId', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.body(null, 400); + await tracker.deleteVideo(videoId); + return c.json(statsJson('deleteVideo', { ok: true })); + }); +} diff --git a/src/core/services/stats-server/mining-routes.ts b/src/core/services/stats-server/mining-routes.ts new file mode 100644 index 00000000..690ac13f --- /dev/null +++ b/src/core/services/stats-server/mining-routes.ts @@ -0,0 +1,469 @@ +import type { Hono } from 'hono'; +import { existsSync } from 'node:fs'; +import { basename } from 'node:path'; +import { AnkiConnectClient } from '../../../anki-connect.js'; +import { getConfiguredWordFieldName } from '../../../anki-field-config.js'; +import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/animated-image-sync.js'; +import { MediaGenerator } from '../../../media-generator.js'; +import { statsJson } from '../../../types/stats-http-contract.js'; +import { + resolveRetimedSecondarySubtitleTextFromSidecar, + resolveSecondarySubtitleTextFromSidecar, +} from '../secondary-subtitle-sidecar.js'; +import { + applyStatsWordAndSentenceCardFields, + createStatsMiningContext, + getStatsDirectMiningAudioFieldNames, + getStatsWordMiningAudioFieldName, + resolveStatsNoteFieldName, + shouldUseStatsLapisKikuCardFields, + statsMiningLogger, + type StatsMiningRouteOptions, + type StatsServerNoteInfo, +} from './mining-support.js'; + +export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteOptions): void { + const { + getAnkiConnectConfig, + getEffectiveMiningDeckName, + getSecondarySubtitleLanguages, + getStatsMiningAlassPath, + timeMiningPhase, + } = createStatsMiningContext(options); + app.post('/api/stats/mine-card', async (c) => { + const body = await c.req.json().catch(() => null); + const sourcePath = typeof body?.sourcePath === 'string' ? body.sourcePath.trim() : ''; + const startMs = typeof body?.startMs === 'number' ? body.startMs : NaN; + const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN; + const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : ''; + const word = typeof body?.word === 'string' ? body.word.trim() : ''; + const bodySecondaryText = + typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : ''; + const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : ''; + const rawMode = c.req.query('mode'); + const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence'; + + if ( + !sourcePath || + !sentence || + (mode === 'word' && !word) || + !Number.isFinite(startMs) || + !Number.isFinite(endMs) + ) { + return c.json( + statsJson('mineCard', { + error: 'sourcePath, sentence, startMs, and endMs are required', + }), + 400, + ); + } + if (endMs <= startMs) { + return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400); + } + + if (!existsSync(sourcePath)) { + return c.json(statsJson('mineCard', { error: 'File not found' }), 404); + } + + const ankiConfig = getAnkiConnectConfig(); + if (!ankiConfig) { + return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500); + } + const addYomitanNote = options?.addYomitanNote; + if (mode === 'word' && !addYomitanNote) { + return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500); + } + const secondarySubtitleLanguages = getSecondarySubtitleLanguages(); + let retimedSecondaryText = ''; + if (mode === 'sentence' && !bodySecondaryText) { + try { + retimedSecondaryText = await ( + options?.resolveRetimedSecondarySubtitleText ?? + resolveRetimedSecondarySubtitleTextFromSidecar + )({ + sourcePath, + startMs, + endMs, + languages: secondarySubtitleLanguages, + alassPath: getStatsMiningAlassPath(), + }); + } catch (error) { + statsMiningLogger.warn( + 'Failed to resolve retimed secondary subtitle for stats mining:', + error instanceof Error ? error.message : String(error), + ); + } + } + const secondaryText = + bodySecondaryText || + retimedSecondaryText || + resolveSecondarySubtitleTextFromSidecar({ + sourcePath, + startMs, + endMs, + languages: secondarySubtitleLanguages, + }); + + const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765'); + const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator(); + + const audioPadding = ankiConfig.media?.audioPadding ?? 0; + const normalizeAudio = ankiConfig.media?.normalizeAudio !== false; + const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30; + + const startSec = startMs / 1000; + const endSec = endMs / 1000; + const rawDuration = endSec - startSec; + const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec; + + const highlightedSentence = word + ? sentence.replace( + new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + `${word}`, + ) + : sentence; + + const generateAudio = ankiConfig.media?.generateAudio !== false; + const generateImage = ankiConfig.media?.generateImage !== false && mode !== 'audio'; + const imageType = ankiConfig.media?.imageType ?? 'static'; + const syncAnimatedImageToWordAudio = + imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false; + + const audioPromise = generateAudio + ? timeMiningPhase(mode, 'generateAudio', () => + mediaGen.generateAudio( + sourcePath, + startSec, + clampedEndSec, + audioPadding, + null, + normalizeAudio, + ), + ) + : Promise.resolve(null); + + const createImagePromise = (animatedLeadInSeconds = 0): Promise => { + if (!generateImage) { + return Promise.resolve(null); + } + + if (imageType === 'avif') { + return timeMiningPhase(mode, 'generateAnimatedImage', () => + mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, { + fps: ankiConfig.media?.animatedFps ?? 10, + maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, + maxHeight: ankiConfig.media?.animatedMaxHeight, + crf: ankiConfig.media?.animatedCrf ?? 35, + leadingStillDuration: animatedLeadInSeconds, + }), + ); + } + + const midpointSec = (startSec + clampedEndSec) / 2; + return timeMiningPhase(mode, 'generateScreenshot', () => + mediaGen.generateScreenshot(sourcePath, midpointSec, { + format: ankiConfig.media?.imageFormat ?? 'jpg', + quality: ankiConfig.media?.imageQuality ?? 92, + maxWidth: ankiConfig.media?.imageMaxWidth, + maxHeight: ankiConfig.media?.imageMaxHeight, + }), + ); + }; + + const imagePromise = + mode === 'word' && syncAnimatedImageToWordAudio + ? Promise.resolve(null) + : createImagePromise(); + + const errors: string[] = []; + let noteId: number; + let effectiveDeckNamePromise: Promise | null = null; + const getEffectiveDeckNameForRequest = (): Promise => { + effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig); + return effectiveDeckNamePromise; + }; + const moveNoteToConfiguredDeck = async (id: number): Promise => { + const deckName = await getEffectiveDeckNameForRequest(); + if (!deckName) { + return; + } + try { + const cardIds = await timeMiningPhase(mode, 'findCards', () => + client.findCards(`nid:${id}`), + ); + await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName)); + } catch (err) { + errors.push(`deck: ${(err as Error).message}`); + } + }; + + if (mode === 'word') { + const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([ + timeMiningPhase( + 'word', + 'addYomitanNote', + () => addYomitanNote!(word), + (noteId) => (typeof noteId === 'number' ? { noteId } : {}), + ), + audioPromise, + imagePromise, + ]); + + if (yomitanResult.status === 'rejected' || !yomitanResult.value) { + return c.json( + statsJson('mineCard', { + error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`, + }), + 502, + ); + } + + noteId = yomitanResult.value; + await moveNoteToConfiguredDeck(noteId); + const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; + if (audioResult.status === 'rejected') + errors.push(`audio: ${(audioResult.reason as Error).message}`); + if (imageResult.status === 'rejected') + errors.push(`image: ${(imageResult.reason as Error).message}`); + + let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; + let noteInfo: StatsServerNoteInfo | null = null; + if ( + audioBuffer || + (syncAnimatedImageToWordAudio && generateImage) || + shouldUseStatsLapisKikuCardFields(ankiConfig) + ) { + try { + const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; + noteInfo = noteInfoResult[0] ?? null; + } catch (err) { + if (syncAnimatedImageToWordAudio && generateImage) { + errors.push(`image: ${(err as Error).message}`); + } + } + } + if (syncAnimatedImageToWordAudio && generateImage) { + try { + const animatedLeadInSeconds = noteInfo + ? await resolveAnimatedImageLeadInSeconds({ + config: ankiConfig, + noteInfo, + resolveConfiguredFieldName: (candidateNoteInfo, ...preferredNames) => + resolveStatsNoteFieldName(candidateNoteInfo, ...preferredNames), + retrieveMediaFileBase64: (filename) => client.retrieveMediaFile(filename), + }) + : 0; + imageBuffer = await createImagePromise(animatedLeadInSeconds); + } catch (err) { + errors.push(`image: ${(err as Error).message}`); + } + } + if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') { + errors.push('audio: no audio generated'); + } + if (generateImage && !imageBuffer) { + errors.push('image: no image generated'); + } + + const mediaFields: Record = {}; + const timestamp = Date.now(); + const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; + const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo); + const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; + + mediaFields[sentenceFieldName] = highlightedSentence; + applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig); + + if (audioBuffer) { + const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`; + try { + await timeMiningPhase('word', 'uploadAudio', () => + client.storeMediaFile(audioFilename, audioBuffer), + ); + mediaFields[audioFieldName] = `[sound:${audioFilename}]`; + } catch (err) { + errors.push(`audio upload: ${(err as Error).message}`); + } + } + + if (imageBuffer) { + const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg'); + const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`; + try { + await timeMiningPhase('word', 'uploadImage', () => + client.storeMediaFile(imageFilename, imageBuffer), + ); + mediaFields[imageFieldName] = ``; + } catch (err) { + errors.push(`image upload: ${(err as Error).message}`); + } + } + + const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? ''; + if (miscInfoFieldName) { + const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)'; + const filenameWithExt = videoTitle || basename(sourcePath); + const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); + const totalMs = Math.floor(startMs); + const totalSec2 = Math.floor(totalMs / 1000); + const hours = String(Math.floor(totalSec2 / 3600)).padStart(2, '0'); + const minutes = String(Math.floor((totalSec2 % 3600) / 60)).padStart(2, '0'); + const secs = String(totalSec2 % 60).padStart(2, '0'); + const ms = String(totalMs % 1000).padStart(3, '0'); + mediaFields[miscInfoFieldName] = pattern + .replace(/%f/g, filenameWithoutExt) + .replace(/%F/g, filenameWithExt) + .replace(/%t/g, `${hours}:${minutes}:${secs}`) + .replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`) + .replace(/
/g, '\n'); + } + + if (Object.keys(mediaFields).length > 0) { + try { + await timeMiningPhase('word', 'updateNoteFields', () => + client.updateNoteFields(noteId, mediaFields), + ); + } catch (err) { + errors.push(`update fields: ${(err as Error).message}`); + } + } + + return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) })); + } + + const wordFieldName = getConfiguredWordFieldName(ankiConfig); + const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; + const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; + const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; + const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? ''; + + const fields: Record = { + [sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence, + }; + + if (mode === 'sentence' && secondaryText) { + fields[translationFieldName] = secondaryText; + } + + if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) { + if (mode === 'sentence') { + fields[wordFieldName] = sentence; + } else if (word) { + fields[wordFieldName] = word; + } + if (mode === 'sentence') { + fields['IsSentenceCard'] = 'x'; + } else if (mode === 'audio') { + fields['IsAudioCard'] = 'x'; + } + } + + const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic'; + const tags = ankiConfig.tags ?? ['SubMiner']; + + const addNotePromise = timeMiningPhase( + mode, + 'addNote', + async () => + client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags), + (id) => ({ + noteId: id, + }), + ); + + const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([ + audioPromise, + imagePromise, + addNotePromise, + ]); + + const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; + const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; + if (audioResult.status === 'rejected') + errors.push(`audio: ${(audioResult.reason as Error).message}`); + if (imageResult.status === 'rejected') + errors.push(`image: ${(imageResult.reason as Error).message}`); + + if (addNoteResult.status === 'rejected') { + return c.json( + statsJson('mineCard', { + error: `Failed to add note: ${(addNoteResult.reason as Error).message}`, + }), + 502, + ); + } + noteId = addNoteResult.value; + await moveNoteToConfiguredDeck(noteId); + + const mediaFields: Record = {}; + const timestamp = Date.now(); + let noteInfo: StatsServerNoteInfo | null = null; + if (audioBuffer) { + try { + const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; + noteInfo = noteInfoResult[0] ?? null; + } catch { + noteInfo = null; + } + } + + if (audioBuffer) { + const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`; + try { + await timeMiningPhase(mode, 'uploadAudio', () => + client.storeMediaFile(audioFilename, audioBuffer), + ); + const audioValue = `[sound:${audioFilename}]`; + for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) { + mediaFields[fieldName] = audioValue; + } + } catch (err) { + errors.push(`audio upload: ${(err as Error).message}`); + } + } + + if (imageBuffer) { + const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg'); + const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`; + try { + await timeMiningPhase(mode, 'uploadImage', () => + client.storeMediaFile(imageFilename, imageBuffer), + ); + mediaFields[imageFieldName] = ``; + } catch (err) { + errors.push(`image upload: ${(err as Error).message}`); + } + } + + if (miscInfoFieldName) { + const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)'; + const filenameWithExt = videoTitle || basename(sourcePath); + const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); + const totalMs = Math.floor(startMs); + const totalSec = Math.floor(totalMs / 1000); + const hours = String(Math.floor(totalSec / 3600)).padStart(2, '0'); + const minutes = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0'); + const secs = String(totalSec % 60).padStart(2, '0'); + const ms = String(totalMs % 1000).padStart(3, '0'); + const miscInfo = pattern + .replace(/%f/g, filenameWithoutExt) + .replace(/%F/g, filenameWithExt) + .replace(/%t/g, `${hours}:${minutes}:${secs}`) + .replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`) + .replace(/
/g, '\n'); + mediaFields[miscInfoFieldName] = miscInfo; + } + + if (Object.keys(mediaFields).length > 0) { + try { + await timeMiningPhase(mode, 'updateNoteFields', () => + client.updateNoteFields(noteId, mediaFields), + ); + } catch (err) { + errors.push(`update fields: ${(err as Error).message}`); + } + } + + return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) })); + }); +} diff --git a/src/core/services/stats-server/mining-support.ts b/src/core/services/stats-server/mining-support.ts new file mode 100644 index 00000000..9a98b318 --- /dev/null +++ b/src/core/services/stats-server/mining-support.ts @@ -0,0 +1,195 @@ +import type { MediaGenerator } from '../../../media-generator.js'; +import type { AnkiConnectConfig } from '../../../types.js'; +import { createLogger } from '../../../logger.js'; +import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js'; + +export type StatsServerNoteInfo = { + noteId: number; + fields: Record; +}; + +export type StatsServerMediaGenerator = { + generateAudio: (...args: Parameters) => Promise; + generateScreenshot: ( + ...args: Parameters + ) => Promise; + generateAnimatedImage: ( + ...args: Parameters + ) => Promise; +}; + +export type StatsMiningTimingEvent = { + mode: 'word' | 'sentence' | 'audio'; + phase: string; + elapsedMs: number; + noteId?: number; +}; + +export type StatsMiningRouteOptions = { + ankiConnectConfig?: AnkiConnectConfig; + getAnkiConnectConfig?: () => AnkiConnectConfig | undefined; + getYomitanAnkiDeckName?: () => Promise | string | null | undefined; + secondarySubtitleLanguages?: string[]; + getSecondarySubtitleLanguages?: () => string[] | undefined; + statsMiningAlassPath?: string; + getStatsMiningAlassPath?: () => string | null | undefined; + resolveRetimedSecondarySubtitleText?: ( + input: RetimedSecondarySubtitleInput, + ) => Promise | string; + addYomitanNote?: (word: string) => Promise; + createMediaGenerator?: () => StatsServerMediaGenerator; + onMiningTiming?: (event: StatsMiningTimingEvent) => void; + nowMs?: () => number; +}; + +type StatsMiningLogger = { + debug: (message: string, ...meta: unknown[]) => void; + warn: (message: string, ...meta: unknown[]) => void; +}; + +export const statsMiningLogger: StatsMiningLogger = createLogger('stats:mining'); + +export function resolveStatsNoteFieldName( + noteInfo: StatsServerNoteInfo, + ...preferredNames: (string | undefined)[] +): string | null { + for (const preferredName of preferredNames) { + if (!preferredName) continue; + const resolved = Object.keys(noteInfo.fields).find( + (fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(), + ); + if (resolved) return resolved; + } + return null; +} + +function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const fieldName of fieldNames) { + const normalized = fieldName?.trim(); + if (!normalized) continue; + const key = normalized.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(normalized); + } + return result; +} + +export function getStatsWordMiningAudioFieldName( + ankiConfig: AnkiConnectConfig, + noteInfo: StatsServerNoteInfo | null, +): string { + return ( + (noteInfo + ? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio) + : null) ?? + ankiConfig.fields?.audio ?? + 'ExpressionAudio' + ); +} + +export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean { + return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true; +} + +export function applyStatsWordAndSentenceCardFields( + fields: Record, + noteInfo: StatsServerNoteInfo | null, + ankiConfig: AnkiConnectConfig, +): void { + if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return; + const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard'); + if (!wordAndSentenceFlag) return; + + fields[wordAndSentenceFlag] = 'x'; + for (const flagName of ['IsSentenceCard', 'IsAudioCard']) { + const resolved = resolveStatsNoteFieldName(noteInfo, flagName); + if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = ''; + } +} + +export function getStatsDirectMiningAudioFieldNames( + ankiConfig: AnkiConnectConfig, + noteInfo: StatsServerNoteInfo | null, + mode: 'sentence' | 'audio', +): string[] { + const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio'; + if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) { + return [configuredAudioField]; + } + const sentenceAudioField = noteInfo + ? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField) + : 'SentenceAudio'; + const expressionAudioField = noteInfo + ? resolveStatsNoteFieldName(noteInfo, configuredAudioField) + : configuredAudioField; + return mode === 'sentence' + ? uniqueFieldNames(sentenceAudioField) + : uniqueFieldNames(sentenceAudioField, expressionAudioField); +} + +export function createStatsMiningContext(options?: StatsMiningRouteOptions) { + const nowMs = options?.nowMs ?? (() => Date.now()); + const getAnkiConnectConfig = (): AnkiConnectConfig | undefined => + options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig; + const getSecondarySubtitleLanguages = (): string[] => + options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? []; + const getStatsMiningAlassPath = (): string | null | undefined => + options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath; + const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise => { + const configuredDeckName = ankiConfig.deck?.trim() ?? ''; + if (configuredDeckName) return configuredDeckName; + try { + const yomitanDeckName = await options?.getYomitanAnkiDeckName?.(); + return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : ''; + } catch (error) { + statsMiningLogger.warn( + 'Failed to resolve Yomitan Anki deck for stats mining:', + error instanceof Error ? error.message : String(error), + ); + return ''; + } + }; + const recordMiningTiming = (event: StatsMiningTimingEvent): void => { + try { + options?.onMiningTiming?.(event); + } catch { + // Timing observers must not affect mining execution. + } + statsMiningLogger.debug( + `[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`, + event, + ); + }; + const timeMiningPhase = async ( + mode: StatsMiningTimingEvent['mode'], + phase: string, + fn: () => Promise, + details?: (value: T) => Partial, + ): Promise => { + const startedAtMs = nowMs(); + try { + const value = await fn(); + recordMiningTiming({ + mode, + phase, + elapsedMs: nowMs() - startedAtMs, + ...details?.(value), + }); + return value; + } catch (error) { + recordMiningTiming({ mode, phase, elapsedMs: nowMs() - startedAtMs }); + throw error; + } + }; + + return { + getAnkiConnectConfig, + getEffectiveMiningDeckName, + getSecondarySubtitleLanguages, + getStatsMiningAlassPath, + timeMiningPhase, + }; +} diff --git a/src/core/services/stats-server/route-support.ts b/src/core/services/stats-server/route-support.ts new file mode 100644 index 00000000..d0050cd6 --- /dev/null +++ b/src/core/services/stats-server/route-support.ts @@ -0,0 +1,258 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { extname, resolve, sep } from 'node:path'; +import { + getConfiguredSentenceFieldName, + getConfiguredTranslationFieldName, + getConfiguredWordFieldName, + getPreferredNoteFieldValue, +} from '../../../anki-field-config.js'; +import type { AnkiConnectConfig } from '../../../types.js'; +import type { StatsExcludedWord } from '../../../types/stats-wire.js'; +import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; +import { splitSentenceSearchTerms } from '../immersion-tracker/query-lexical.js'; + +const STATS_STATIC_CONTENT_TYPES: Record = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +export function parseIntQuery( + raw: string | undefined, + fallback: number, + maxLimit?: number, +): number { + if (raw === undefined) return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return fallback; + const parsed = Math.floor(n); + return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); +} + +export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' { + return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all' + ? raw + : '30d'; +} + +export function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' { + return raw === 'month' ? 'month' : 'day'; +} + +export function parseTrendFillEmpty(raw: string | undefined): boolean { + return raw !== 'false'; +} + +export function parseEventTypesQuery(raw: string | undefined): number[] | undefined { + if (!raw) return undefined; + const parsed = raw + .split(',') + .map((entry) => Number.parseInt(entry.trim(), 10)) + .filter((entry) => Number.isInteger(entry) && entry > 0); + return parsed.length > 0 ? parsed : undefined; +} + +export function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | null { + if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) { + return null; + } + + const words: StatsExcludedWord[] = []; + for (const row of (body as { words: unknown[] }).words) { + if (!row || typeof row !== 'object') return null; + const { headword, word, reading } = row as Record; + if (typeof headword !== 'string' || typeof word !== 'string' || typeof reading !== 'string') { + return null; + } + words.push({ headword, word, reading }); + } + return words; +} + +export function loadKnownWordsSet(cachePath: string | undefined): Set | null { + if (!cachePath || !existsSync(cachePath)) return null; + try { + const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { + version?: number; + words?: string[]; + notes?: Record>; + }; + if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) { + return new Set(raw.words); + } + if (raw.version === 3 && raw.notes && typeof raw.notes === 'object') { + const words = new Set(); + for (const entries of Object.values(raw.notes)) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (entry && typeof entry.word === 'string' && entry.word) words.add(entry.word); + } + } + return words; + } + } catch { + // Treat an unreadable cache as unavailable. + } + return null; +} + +export function countKnownWords( + headwords: string[], + knownWordsSet: Set, +): { totalUniqueWords: number; knownWordCount: number } { + let knownWordCount = 0; + for (const headword of headwords) { + if (knownWordsSet.has(headword)) knownWordCount += 1; + } + return { totalUniqueWords: headwords.length, knownWordCount }; +} + +function toKnownWordRate(knownWordsSeen: number, tokensSeen: number): number { + if (!Number.isFinite(knownWordsSeen) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) return 0; + return Number(((knownWordsSeen / tokensSeen) * 100).toFixed(1)); +} + +function summarizeFilteredWordOccurrences( + wordsByLine: Array<{ lineIndex: number; headword: string; occurrenceCount: number }>, + knownWordsSet: Set, +): { knownWordsSeen: number; totalWordsSeen: number } { + let knownWordsSeen = 0; + let totalWordsSeen = 0; + for (const row of wordsByLine) { + totalWordsSeen += row.occurrenceCount; + if (knownWordsSet.has(row.headword)) knownWordsSeen += row.occurrenceCount; + } + return { knownWordsSeen, totalWordsSeen }; +} + +export async function enrichSessionsWithKnownWordMetrics< + Session extends { sessionId: number; tokensSeen: number }, +>( + tracker: ImmersionTrackerService, + sessions: Session[], + knownWordsCachePath?: string, +): Promise> { + const knownWordsSet = loadKnownWordsSet(knownWordsCachePath); + if (!knownWordsSet) { + return sessions.map((session) => ({ ...session, knownWordsSeen: 0, knownWordRate: 0 })); + } + + return Promise.all( + sessions.map(async (session) => { + let knownWordsSeen = 0; + let totalWordsSeen = 0; + try { + const summary = summarizeFilteredWordOccurrences( + await tracker.getSessionWordsByLine(session.sessionId), + knownWordsSet, + ); + knownWordsSeen = summary.knownWordsSeen; + totalWordsSeen = summary.totalWordsSeen; + } catch { + knownWordsSeen = 0; + totalWordsSeen = 0; + } + return { + ...session, + knownWordsSeen, + knownWordRate: toKnownWordRate(knownWordsSeen, totalWordsSeen), + }; + }), + ); +} + +export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean { + if (raw === undefined) return fallback; + const normalized = raw.trim().toLowerCase(); + if (!normalized) return fallback; + return !['0', 'false', 'no', 'off'].includes(normalized); +} + +function uniqueNonEmptyStrings(values: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +export async function buildSentenceSearchOptions( + query: string, + searchByHeadword: boolean, + resolveSentenceSearchHeadwords: ((term: string) => Promise | string[]) | undefined, +): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> { + if (!searchByHeadword) return undefined; + const headwordTerms: Array<{ term: string; headwords: string[] }> = []; + for (const term of splitSentenceSearchTerms(query)) { + const resolved = resolveSentenceSearchHeadwords + ? await resolveSentenceSearchHeadwords(term) + : [term]; + const headwords = uniqueNonEmptyStrings(resolved); + if (headwords.length > 0) headwordTerms.push({ term, headwords }); + } + return headwordTerms.length > 0 ? { headwordTerms } : undefined; +} + +export function buildAnkiNotePreview( + fields: Record, + ankiConfig?: Pick, +): { word: string; sentence: string; translation: string } { + return { + word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]), + sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]), + translation: getPreferredNoteFieldValue(fields, [ + getConfiguredTranslationFieldName(ankiConfig), + ]), + }; +} + +function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null { + const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html'; + const absoluteStaticDir = resolve(staticDir); + let decodedPath: string; + try { + decodedPath = decodeURIComponent(normalizedPath); + } catch { + return null; + } + const absolutePath = resolve(absoluteStaticDir, decodedPath); + if ( + absolutePath !== absoluteStaticDir && + !absolutePath.startsWith(`${absoluteStaticDir}${sep}`) + ) { + return null; + } + if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) return null; + return absolutePath; +} + +export function createStatsStaticResponse(staticDir: string, requestPath: string): Response | null { + const absolutePath = resolveStatsStaticPath(staticDir, requestPath); + if (!absolutePath) return null; + const contentType = + STATS_STATIC_CONTENT_TYPES[extname(absolutePath).toLowerCase()] ?? 'application/octet-stream'; + return new Response(readFileSync(absolutePath), { + headers: { + 'Content-Type': contentType, + 'Cache-Control': absolutePath.endsWith('index.html') + ? 'no-cache' + : 'public, max-age=31536000, immutable', + }, + }); +} diff --git a/src/core/services/stats-server/routes.ts b/src/core/services/stats-server/routes.ts new file mode 100644 index 00000000..79dc98f5 --- /dev/null +++ b/src/core/services/stats-server/routes.ts @@ -0,0 +1,5 @@ +export { registerStatsAnalyticsRoutes } from './analytics-routes.js'; +export { registerStatsIntegrationRoutes } from './integration-routes.js'; +export { registerStatsLibraryRoutes } from './library-routes.js'; +export { registerStatsMiningRoutes } from './mining-routes.js'; +export { registerStatsStaticRoutes } from './static-routes.js'; diff --git a/src/core/services/stats-server/static-routes.ts b/src/core/services/stats-server/static-routes.ts new file mode 100644 index 00000000..6202936b --- /dev/null +++ b/src/core/services/stats-server/static-routes.ts @@ -0,0 +1,26 @@ +import type { Hono } from 'hono'; +import { createStatsStaticResponse } from './route-support.js'; + +export function registerStatsStaticRoutes(app: Hono, staticDir?: string): void { + if (!staticDir) return; + + app.get('/assets/*', (c) => { + const response = createStatsStaticResponse(staticDir, c.req.path); + if (!response) return c.text('Not found', 404); + return response; + }); + + app.get('/index.html', (c) => { + const response = createStatsStaticResponse(staticDir, '/index.html'); + if (!response) return c.text('Stats UI not built', 404); + return response; + }); + + app.get('*', (c) => { + const staticResponse = createStatsStaticResponse(staticDir, c.req.path); + if (staticResponse) return staticResponse; + const fallback = createStatsStaticResponse(staticDir, '/index.html'); + if (!fallback) return c.text('Stats UI not built', 404); + return fallback; + }); +} diff --git a/src/stats-transport-architecture.test.ts b/src/stats-transport-architecture.test.ts index f571d222..8828b7d2 100644 --- a/src/stats-transport-architecture.test.ts +++ b/src/stats-transport-architecture.test.ts @@ -23,7 +23,17 @@ test('stats data uses the shared HTTP contract while native dialogs retain IPC', assert.doesNotMatch(ipcHandlers, /statsGet[A-Z]/); const apiClient = read('stats/src/lib/api-client.ts'); - const statsServer = read('src/core/services/stats-server.ts'); + const statsServerRoutes = [ + 'analytics-routes.ts', + 'integration-routes.ts', + 'library-routes.ts', + 'mining-routes.ts', + ].map((filename) => ({ + filename, + source: read(`src/core/services/stats-server/${filename}`), + })); assert.match(apiClient, /StatsHttpClient/); - assert.match(statsServer, /statsJson/); + for (const { filename, source } of statsServerRoutes) { + assert.match(source, /statsJson/, `${filename} must use the shared HTTP contract`); + } });