diff --git a/backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md b/backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md index 6c53fdc..3fb2a05 100644 --- a/backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md +++ b/backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md @@ -4,7 +4,7 @@ title: Refactor large files for maintainability and readability status: In Progress assignee: [] created_date: '2026-02-19 09:46' -updated_date: '2026-02-20 11:42' +updated_date: '2026-02-21 03:29' labels: - architecture - refactor @@ -45,6 +45,9 @@ Several core files are oversized and high-coupling (`src/main.ts`, `src/anki-int - 2026-02-20: Large `src/main.ts` composition slices extracted into runtime handler modules (startup, CLI, tray/window/bootstrap, shortcuts, OSD/secondary-sub, numeric/overlay shortcut lifecycle, and related seams) with focused parity tests. - 2026-02-20: `src/anki-integration.ts` constructor decomposed into targeted private factory/config methods (`normalizeConfig`, `createKnownWordCache`, `createPollingRunner`, `createCardCreationService`, `createFieldGroupingService`) to reduce orchestration complexity. - 2026-02-20: `src/config/service.ts` load/apply duplication reduced by introducing `applyResolvedConfig`, `resolveExistingConfigPath`, and `parseConfigContent`. +- 2026-02-20: added `src/main/runtime/domains/*` barrels plus `src/main/runtime/registry.ts`; migrated `src/main.ts` runtime import paths to domain barrels and added `check:main-fanin` guardrail script. +- 2026-02-21: extracted additional `src/main.ts` deps-builder orchestration into composer modules (`jellyfin-remote`, `anilist-setup`) with focused tests; tightened main fan-in guard (`<=110` import lines, `<=11` unique paths). +- Remaining gap for TASK-85/TASK-94: continue composer extraction for startup/overlay/ipc/shortcuts to reach fully thin composition root. - Validation checkpoint: `bun run build` and focused suites (`dist/config/config.test.js`, `dist/anki-integration.test.js`) passing. - Current strategy: prioritize high-ROI extractions (behavioral seams and churn-heavy hotspots) over further low-impact micro-shuffles. @@ -52,15 +55,27 @@ Several core files are oversized and high-coupling (`src/main.ts`, `src/anki-int ## Acceptance Criteria - [ ] #1 `src/main.ts` reduced to orchestration-focused module with extracted runtime domains -- [ ] #2 `src/anki-integration.ts` reduced to facade with helper collaborators -- [ ] #3 Config and immersion tracker services decomposed without behavior regressions +- [x] #2 `src/anki-integration.ts` reduced to facade with helper collaborators +- [x] #3 Config and immersion tracker services decomposed without behavior regressions - [ ] #4 `subminer` generated artifact ownership/workflow documented and enforced -- [ ] #5 Full build + config/core tests pass after refactor +- [x] #5 Full build + config/core tests pass after refactor +## Implementation Notes + + +TASK-95 completion slice evidence: decomposed hotspot collaborators and reduced LOC across all remaining core hotspots. + +TASK-95 LOC deltas: `src/anki-integration.ts` -407 (1722 -> 1315), `src/config/service.ts` -1492 (1591 -> 99), `src/core/services/immersion-tracker-service.ts` -361 (1470 -> 1109). + +TASK-95 extracted collaborators: `src/anki-integration/field-grouping-merge.ts`, `src/config/{load.ts,parse.ts,warnings.ts,resolve.ts}`, `src/core/services/immersion-tracker/{types.ts,reducer.ts,queue.ts,maintenance.ts,query.ts}`. + +TASK-95 verification evidence: `bun run build`, `bun run test:config:dist`, `bun run test:core:dist`, `bun run check:file-budgets` completed with no failing tests. + + ## Definition of Done - [ ] #1 Plan at `docs/plans/2026-02-19-repo-maintainability-refactor-plan.md` executed or decomposed into child tasks -- [ ] #2 Regression coverage added for extracted seams +- [x] #2 Regression coverage added for extracted seams - [ ] #3 Docs updated for architecture and contributor workflow changes diff --git a/backlog/tasks/task-95 - Decompose-remaining-oversized-core-hotspots.md b/backlog/tasks/task-95 - Decompose-remaining-oversized-core-hotspots.md new file mode 100644 index 0000000..e294957 --- /dev/null +++ b/backlog/tasks/task-95 - Decompose-remaining-oversized-core-hotspots.md @@ -0,0 +1,78 @@ +--- +id: TASK-95 +title: Decompose remaining oversized core hotspots +status: Done +assignee: + - codex-task95-hotspots +created_date: '2026-02-20 12:06' +updated_date: '2026-02-21 03:29' +labels: + - architecture + - refactor + - maintainability +dependencies: + - TASK-85 +references: + - docs/plans/2026-02-21-task-95-hotspot-decomposition-plan.md +priority: high +--- + +## Description + + +Three core files remain materially oversized and unfinished: `src/anki-integration.ts`, `src/config/service.ts`, and `src/core/services/immersion-tracker-service.ts`. Complete decomposition into focused collaborators/modules with behavior preserved and regression coverage. + + +## Action Steps + + +1. Capture per-file baseline LOC and key responsibilities (constructor orchestration, phase lifecycle, persistence/sync/event flow). +2. For `src/anki-integration.ts`: extract facade collaborators by domain (config/field resolution/media/card creation/notifications) and reduce class surface to orchestration. +3. For `src/config/service.ts`: split lifecycle phases into explicit modules (`load`, `migrate`, `validate`, `warnings`) and keep API stable. +4. For `src/core/services/immersion-tracker-service.ts`: separate state/reducer, persistence adapter, and sync/reporting orchestration. +5. Add focused tests for extracted modules and preserve existing integration-level assertions. +6. Run full build + config/core tests + file-budget checks; record before/after LOC metrics. +7. Update `TASK-85` AC/DoD linkage to mark completed sub-scope with evidence. + + +## Acceptance Criteria + +- [x] #1 `src/anki-integration.ts`, `src/config/service.ts`, and `src/core/services/immersion-tracker-service.ts` are each reduced with clear collaborator boundaries. +- [x] #2 Public behavior remains unchanged (existing config/core tests pass). +- [x] #3 New seam tests cover extracted collaborators/modules. +- [x] #4 File-budget report reflects measurable LOC reduction on all three hotspots. + + +## Implementation Plan + + +Execution plan of record: `docs/plans/2026-02-21-task-95-hotspot-decomposition-plan.md` + +1. Capture baseline LOC and file-budget status for the 3 hotspot files. +2. Add characterization seam tests before extraction in each domain (Anki, Config, Immersion). +3. Decompose each hotspot behind stable facades by extracting focused collaborators/modules. +4. Use parallel subagents for independent hotspot workstreams; integrate safely on shared boundaries. +5. Run required gates (`bun run build`, `bun run test:config:dist`, `bun run test:core:dist`, `bun run check:file-budgets`). +6. Record before/after LOC plus ownership map; update TASK-95 AC/DoD and TASK-85 progress evidence. + + +## Implementation Notes + + +Baseline LOC (before): `src/anki-integration.ts` 1722, `src/config/service.ts` 1591, `src/core/services/immersion-tracker-service.ts` 1470. + +After LOC: `src/anki-integration.ts` 1315 (-407), `src/config/service.ts` 99 (-1492), `src/core/services/immersion-tracker-service.ts` 1109 (-361). + +Module ownership map: Anki -> `src/anki-integration/field-grouping-merge.ts`; Config -> `src/config/{load.ts,parse.ts,warnings.ts,resolve.ts}`; Immersion -> `src/core/services/immersion-tracker/{types.ts,reducer.ts,queue.ts,maintenance.ts,query.ts}`. + +Seam tests added: `src/anki-integration.test.ts` (field-grouping merge seams), `src/config/config.test.ts` (loader/strict reload/warning determinism seams), `src/core/services/immersion-tracker-service.test.ts` (queue/reducer/month-key seams). + +Verification gates passed: `bun run build`, `bun run test:config:dist` (43 pass), `bun run test:core:dist` (204 pass, 10 skipped, 0 fail), `bun run check:file-budgets` (warning-mode report with all three hotspot LOC reduced). + + +## Definition of Done + +- [x] #1 Refactor notes include before/after LOC and module ownership map for all three files. +- [x] #2 `bun run build`, `bun run test:config:dist`, `bun run test:core:dist` pass. +- [x] #3 `TASK-85` progress/AC reflects this completion slice with evidence links. + diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 1c528d1..90e0c3d 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -4,6 +4,8 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { AnkiIntegration } from './anki-integration'; +import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; +import { AnkiConnectConfig } from './types'; interface IntegrationTestContext { integration: AnkiIntegration; @@ -92,6 +94,60 @@ function cleanupIntegrationTestContext(ctx: IntegrationTestContext): void { fs.rmSync(ctx.stateDir, { recursive: true, force: true }); } +function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null { + const exact = availableFieldNames.find((name) => name === preferredName); + if (exact) return exact; + + const lower = preferredName.toLowerCase(); + return availableFieldNames.find((name) => name.toLowerCase() === lower) ?? null; +} + +function createFieldGroupingMergeCollaborator(options?: { + config?: Partial; + currentSubtitleText?: string; + generatedMedia?: { + audioField?: string; + audioValue?: string; + imageField?: string; + imageValue?: string; + miscInfoValue?: string; + }; +}): FieldGroupingMergeCollaborator { + const config = { + fields: { + sentence: 'Sentence', + audio: 'ExpressionAudio', + image: 'Picture', + ...(options?.config?.fields ?? {}), + }, + ...(options?.config ?? {}), + } as AnkiConnectConfig; + + return new FieldGroupingMergeCollaborator({ + getConfig: () => config, + getEffectiveSentenceCardConfig: () => ({ + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + }), + getCurrentSubtitleText: () => options?.currentSubtitleText, + resolveFieldName, + resolveNoteFieldName: (noteInfo, preferredName) => { + if (!preferredName) return null; + return resolveFieldName(Object.keys(noteInfo.fields), preferredName); + }, + extractFields: (fields) => { + const result: Record = {}; + for (const [key, value] of Object.entries(fields)) { + result[key.toLowerCase()] = value.value || ''; + } + return result; + }, + processSentence: (mpvSentence) => `${mpvSentence}::processed`, + generateMediaForMerge: async () => options?.generatedMedia ?? {}, + warnFieldParseOnce: () => undefined, + }); +} + test('AnkiIntegration.refreshKnownWordCache bypasses stale checks', async () => { const ctx = createIntegrationTestContext(); @@ -152,3 +208,61 @@ test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', cleanupIntegrationTestContext(ctx); } }); + +test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => { + const collaborator = createFieldGroupingMergeCollaborator(); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 101, + 202, + { + noteId: 101, + fields: { + SentenceAudio: { value: '[sound:keep.mp3]' }, + ExpressionAudio: { value: '[sound:stale.mp3]' }, + }, + }, + { + noteId: 202, + fields: { + SentenceAudio: { value: '[sound:new.mp3]' }, + }, + }, + false, + ); + + assert.equal( + merged.SentenceAudio, + '[sound:keep.mp3][sound:new.mp3]', + ); + assert.equal(merged.ExpressionAudio, merged.SentenceAudio); +}); + +test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => { + const collaborator = createFieldGroupingMergeCollaborator({ + generatedMedia: { + audioField: 'SentenceAudio', + audioValue: '[sound:generated.mp3]', + }, + }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 11, + 22, + { + noteId: 11, + fields: { + SentenceAudio: { value: '' }, + }, + }, + { + noteId: 22, + fields: { + SentenceAudio: { value: '' }, + }, + }, + true, + ); + + assert.equal(merged.SentenceAudio, '[sound:generated.mp3]'); +}); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index ccaa8c4..727f44d 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -45,6 +45,7 @@ import { PollingRunner } from './anki-integration/polling'; import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate'; import { CardCreationService } from './anki-integration/card-creation'; import { FieldGroupingService } from './anki-integration/field-grouping'; +import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; const log = createLogger('anki').child('integration'); @@ -69,13 +70,6 @@ export class AnkiIntegration { private updateInProgress = false; private uiFeedbackState: UiFeedbackState = createUiFeedbackState(); private parseWarningKeys = new Set(); - private readonly strictGroupingFieldDefaults = new Set([ - 'picture', - 'sentence', - 'sentenceaudio', - 'sentencefurigana', - 'miscinfo', - ]); private fieldGroupingCallback: | ((data: { original: KikuDuplicateCardInfo; @@ -84,6 +78,7 @@ export class AnkiIntegration { | null = null; private knownWordCache: KnownWordCacheManager; private cardCreationService: CardCreationService; + private fieldGroupingMergeCollaborator: FieldGroupingMergeCollaborator; private fieldGroupingService: FieldGroupingService; constructor( @@ -109,9 +104,27 @@ export class AnkiIntegration { this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); this.pollingRunner = this.createPollingRunner(); this.cardCreationService = this.createCardCreationService(); + this.fieldGroupingMergeCollaborator = this.createFieldGroupingMergeCollaborator(); this.fieldGroupingService = this.createFieldGroupingService(); } + private createFieldGroupingMergeCollaborator(): FieldGroupingMergeCollaborator { + return new FieldGroupingMergeCollaborator({ + getConfig: () => this.config, + getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(), + getCurrentSubtitleText: () => this.mpvClient.currentSubText, + resolveFieldName: (availableFieldNames, preferredName) => + this.resolveFieldName(availableFieldNames, preferredName), + resolveNoteFieldName: (noteInfo, preferredName) => + this.resolveNoteFieldName(noteInfo, preferredName), + extractFields: (fields) => this.extractFields(fields), + processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields), + generateMediaForMerge: () => this.generateMediaForMerge(), + warnFieldParseOnce: (fieldName, reason, detail) => + this.warnFieldParseOnce(fieldName, reason, detail), + }); + } + private normalizeConfig(config: AnkiConnectConfig): AnkiConnectConfig { return { ...DEFAULT_ANKI_CONNECT_CONFIG, @@ -281,14 +294,14 @@ export class AnkiIntegration { deleteNoteInfo, includeGeneratedMedia, ) => - this.computeFieldGroupingMergedFields( + this.fieldGroupingMergeCollaborator.computeFieldGroupingMergedFields( keepNoteId, deleteNoteId, keepNoteInfo, deleteNoteInfo, includeGeneratedMedia, ), - getNoteFieldMap: (noteInfo) => this.getNoteFieldMap(noteInfo), + getNoteFieldMap: (noteInfo) => this.fieldGroupingMergeCollaborator.getNoteFieldMap(noteInfo), handleFieldGroupingAuto: (originalNoteId, newNoteId, newNoteInfo, expression) => this.handleFieldGroupingAuto(originalNoteId, newNoteId, newNoteInfo, expression), handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) => @@ -982,27 +995,6 @@ export class AnkiIntegration { }); } - private getGroupableFieldNames(): string[] { - const fields: string[] = []; - fields.push('Sentence'); - fields.push('SentenceAudio'); - fields.push('Picture'); - if (this.config.fields?.image) fields.push(this.config.fields?.image); - if (this.config.fields?.sentence) fields.push(this.config.fields?.sentence); - if ( - this.config.fields?.audio && - this.config.fields?.audio.toLowerCase() !== 'expressionaudio' - ) { - fields.push(this.config.fields?.audio); - } - const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); - const sentenceAudioField = sentenceCardConfig.audioField; - if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField); - if (this.config.fields?.miscInfo) fields.push(this.config.fields?.miscInfo); - fields.push('SentenceFurigana'); - return fields; - } - private getPreferredSentenceAudioFieldName(): string { const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); return sentenceCardConfig.audioField || 'SentenceAudio'; @@ -1015,250 +1007,6 @@ export class AnkiIntegration { ); } - private extractUngroupedValue(value: string): string { - const groupedSpanRegex = /[\s\S]*?<\/span>/gi; - const ungrouped = value.replace(groupedSpanRegex, '').trim(); - if (ungrouped) return ungrouped; - return value.trim(); - } - - private extractLastSoundTag(value: string): string { - const matches = value.match(/\[sound:[^\]]+\]/g); - if (!matches || matches.length === 0) return ''; - return matches[matches.length - 1]!; - } - - private extractLastImageTag(value: string): string { - const matches = value.match(/]*>/gi); - if (!matches || matches.length === 0) return ''; - return matches[matches.length - 1]!; - } - - private extractImageTags(value: string): string[] { - const matches = value.match(/]*>/gi); - return matches || []; - } - - private ensureImageGroupId(imageTag: string, groupId: number): string { - if (!imageTag) return ''; - if (/data-group-id=/i.test(imageTag)) { - return imageTag.replace(/data-group-id="[^"]*"/i, `data-group-id="${groupId}"`); - } - return imageTag.replace(/]*data-group-id="([^"]*)"[^>]*>/gi; - let malformed; - while ((malformed = malformedIdRegex.exec(value)) !== null) { - const rawId = malformed[1]; - const groupId = Number(rawId); - if (!Number.isFinite(groupId) || groupId <= 0) { - this.warnFieldParseOnce(fieldName, 'invalid-group-id', rawId); - } - } - - const spanRegex = /]*>([\s\S]*?)<\/span>/gi; - let match; - while ((match = spanRegex.exec(value)) !== null) { - const groupId = Number(match[1]); - if (!Number.isFinite(groupId) || groupId <= 0) continue; - const content = this.normalizeStrictGroupedValue(match[2] || '', fieldName); - if (!content) { - this.warnFieldParseOnce(fieldName, 'empty-group-content'); - log.debug('Skipping span with empty normalized content', { - fieldName, - rawContent: (match[2] || '').slice(0, 120), - }); - continue; - } - entries.push({ groupId, content }); - } - if (entries.length === 0 && /(); - for (const entry of entries) { - const key = `${entry.groupId}::${entry.content}`; - if (seen.has(key)) continue; - seen.add(key); - unique.push(entry); - } - return unique; - } - - private parsePictureEntries( - value: string, - fallbackGroupId: number, - ): { groupId: number; tag: string }[] { - const tags = this.extractImageTags(value); - const result: { groupId: number; tag: string }[] = []; - for (const tag of tags) { - const idMatch = tag.match(/data-group-id="(\d+)"/i); - let groupId = fallbackGroupId; - if (idMatch) { - const parsed = Number(idMatch[1]); - if (!Number.isFinite(parsed) || parsed <= 0) { - this.warnFieldParseOnce('Picture', 'invalid-group-id', idMatch[1]); - } else { - groupId = parsed; - } - } - const normalizedTag = this.ensureImageGroupId(tag, groupId); - if (!normalizedTag) { - this.warnFieldParseOnce('Picture', 'empty-image-tag'); - continue; - } - result.push({ groupId, tag: normalizedTag }); - } - return result; - } - - private normalizeStrictGroupedValue(value: string, fieldName: string): string { - const ungrouped = this.extractUngroupedValue(value); - if (!ungrouped) return ''; - - const normalizedField = fieldName.toLowerCase(); - if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') { - const lastSoundTag = this.extractLastSoundTag(ungrouped); - if (!lastSoundTag) { - this.warnFieldParseOnce(fieldName, 'missing-sound-tag'); - } - return lastSoundTag || ungrouped; - } - - if (normalizedField === 'picture') { - const lastImageTag = this.extractLastImageTag(ungrouped); - if (!lastImageTag) { - this.warnFieldParseOnce(fieldName, 'missing-image-tag'); - } - return lastImageTag || ungrouped; - } - - return ungrouped; - } - - private getStrictSpanGroupingFields(): Set { - const strictFields = new Set(this.strictGroupingFieldDefaults); - const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); - strictFields.add((sentenceCardConfig.sentenceField || 'sentence').toLowerCase()); - strictFields.add((sentenceCardConfig.audioField || 'sentenceaudio').toLowerCase()); - if (this.config.fields?.image) strictFields.add(this.config.fields.image.toLowerCase()); - if (this.config.fields?.miscInfo) strictFields.add(this.config.fields.miscInfo.toLowerCase()); - return strictFields; - } - - private shouldUseStrictSpanGrouping(fieldName: string): boolean { - const normalized = fieldName.toLowerCase(); - return this.getStrictSpanGroupingFields().has(normalized); - } - - private applyFieldGrouping( - existingValue: string, - newValue: string, - keepGroupId: number, - sourceGroupId: number, - fieldName: string, - ): string { - if (this.shouldUseStrictSpanGrouping(fieldName)) { - if (fieldName.toLowerCase() === 'picture') { - const keepEntries = this.parsePictureEntries(existingValue, keepGroupId); - const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId); - if (keepEntries.length === 0 && sourceEntries.length === 0) { - return existingValue || newValue; - } - const mergedTags = keepEntries.map((entry) => - this.ensureImageGroupId(entry.tag, entry.groupId), - ); - const seen = new Set(mergedTags); - for (const entry of sourceEntries) { - const normalized = this.ensureImageGroupId(entry.tag, entry.groupId); - if (seen.has(normalized)) continue; - seen.add(normalized); - mergedTags.push(normalized); - } - return mergedTags.join(''); - } - - const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName); - const sourceEntries = this.parseStrictEntries(newValue, sourceGroupId, fieldName); - if (keepEntries.length === 0 && sourceEntries.length === 0) { - return existingValue || newValue; - } - if (sourceEntries.length === 0) { - return keepEntries - .map((entry) => `${entry.content}`) - .join(''); - } - const merged = [...keepEntries]; - const seen = new Set(keepEntries.map((entry) => `${entry.groupId}::${entry.content}`)); - for (const entry of sourceEntries) { - const key = `${entry.groupId}::${entry.content}`; - if (seen.has(key)) continue; - seen.add(key); - merged.push(entry); - } - if (merged.length === 0) return existingValue; - return merged - .map((entry) => `${entry.content}`) - .join(''); - } - - if (!existingValue.trim()) return newValue; - if (!newValue.trim()) return existingValue; - - const hasGroups = /data-group-id/.test(existingValue); - - if (!hasGroups) { - return `${existingValue}\n` + newValue; - } - - const groupedSpanRegex = /[\s\S]*?<\/span>/g; - let lastEnd = 0; - let result = ''; - let match; - - while ((match = groupedSpanRegex.exec(existingValue)) !== null) { - const before = existingValue.slice(lastEnd, match.index); - if (before.trim()) { - result += `${before.trim()}\n`; - } - result += match[0] + '\n'; - lastEnd = match.index + match[0].length; - } - - const after = existingValue.slice(lastEnd); - if (after.trim()) { - result += `\n${after.trim()}`; - } - - return result + '\n' + newValue; - } - private async generateMediaForMerge(): Promise<{ audioField?: string; audioValue?: string; @@ -1317,161 +1065,6 @@ export class AnkiIntegration { return result; } - private getResolvedFieldValue(noteInfo: NoteInfo, preferredFieldName?: string): string { - if (!preferredFieldName) return ''; - const resolved = this.resolveNoteFieldName(noteInfo, preferredFieldName); - if (!resolved) return ''; - return noteInfo.fields[resolved]?.value || ''; - } - - private async computeFieldGroupingMergedFields( - keepNoteId: number, - deleteNoteId: number, - keepNoteInfo: NoteInfo, - deleteNoteInfo: NoteInfo, - includeGeneratedMedia: boolean, - ): Promise> { - const groupableFields = this.getGroupableFieldNames(); - const keepFieldNames = Object.keys(keepNoteInfo.fields); - const sourceFields: Record = {}; - const resolvedKeepFieldByPreferred = new Map(); - for (const preferredFieldName of groupableFields) { - sourceFields[preferredFieldName] = this.getResolvedFieldValue( - deleteNoteInfo, - preferredFieldName, - ); - const keepResolved = this.resolveFieldName(keepFieldNames, preferredFieldName); - if (keepResolved) { - resolvedKeepFieldByPreferred.set(preferredFieldName, keepResolved); - } - } - - if (!sourceFields['SentenceFurigana'] && sourceFields['Sentence']) { - sourceFields['SentenceFurigana'] = sourceFields['Sentence']; - } - if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) { - sourceFields['Sentence'] = sourceFields['SentenceFurigana']; - } - if (!sourceFields['Expression'] && sourceFields['Word']) { - sourceFields['Expression'] = sourceFields['Word']; - } - if (!sourceFields['Word'] && sourceFields['Expression']) { - sourceFields['Word'] = sourceFields['Expression']; - } - if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) { - sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio']; - } - if (!sourceFields['ExpressionAudio'] && sourceFields['SentenceAudio']) { - sourceFields['ExpressionAudio'] = sourceFields['SentenceAudio']; - } - - if ( - this.config.fields?.sentence && - !sourceFields[this.config.fields?.sentence] && - this.mpvClient.currentSubText - ) { - const deleteFields = this.extractFields(deleteNoteInfo.fields); - sourceFields[this.config.fields?.sentence] = this.processSentence( - this.mpvClient.currentSubText, - deleteFields, - ); - } - - if (includeGeneratedMedia) { - const media = await this.generateMediaForMerge(); - if (media.audioField && media.audioValue && !sourceFields[media.audioField]) { - sourceFields[media.audioField] = media.audioValue; - } - if (media.imageField && media.imageValue && !sourceFields[media.imageField]) { - sourceFields[media.imageField] = media.imageValue; - } - if ( - this.config.fields?.miscInfo && - media.miscInfoValue && - !sourceFields[this.config.fields?.miscInfo] - ) { - sourceFields[this.config.fields?.miscInfo] = media.miscInfoValue; - } - } - - const mergedFields: Record = {}; - for (const preferredFieldName of groupableFields) { - const keepFieldName = resolvedKeepFieldByPreferred.get(preferredFieldName); - if (!keepFieldName) continue; - - const keepFieldNormalized = keepFieldName.toLowerCase(); - if ( - keepFieldNormalized === 'expression' || - keepFieldNormalized === 'expressionfurigana' || - keepFieldNormalized === 'expressionreading' || - keepFieldNormalized === 'expressionaudio' - ) { - continue; - } - - const existingValue = keepNoteInfo.fields[keepFieldName]?.value || ''; - const newValue = sourceFields[preferredFieldName] || ''; - const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName); - if (!existingValue.trim() && !newValue.trim()) continue; - - if (isStrictField) { - mergedFields[keepFieldName] = this.applyFieldGrouping( - existingValue, - newValue, - keepNoteId, - deleteNoteId, - keepFieldName, - ); - } else if (existingValue.trim() && newValue.trim()) { - mergedFields[keepFieldName] = this.applyFieldGrouping( - existingValue, - newValue, - keepNoteId, - deleteNoteId, - keepFieldName, - ); - } else { - if (!newValue.trim()) continue; - mergedFields[keepFieldName] = newValue; - } - } - - // Keep sentence/expression audio fields aligned after grouping. Otherwise a - // kept note can retain stale ExpressionAudio while SentenceAudio is merged. - const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); - const resolvedSentenceAudioField = this.resolveFieldName( - keepFieldNames, - sentenceCardConfig.audioField || 'SentenceAudio', - ); - const resolvedExpressionAudioField = this.resolveFieldName( - keepFieldNames, - this.config.fields?.audio || 'ExpressionAudio', - ); - if ( - resolvedSentenceAudioField && - resolvedExpressionAudioField && - resolvedExpressionAudioField !== resolvedSentenceAudioField - ) { - const mergedSentenceAudioValue = - mergedFields[resolvedSentenceAudioField] || - keepNoteInfo.fields[resolvedSentenceAudioField]?.value || - ''; - if (mergedSentenceAudioValue.trim()) { - mergedFields[resolvedExpressionAudioField] = mergedSentenceAudioValue; - } - } - - return mergedFields; - } - - private getNoteFieldMap(noteInfo: NoteInfo): Record { - const fields: Record = {}; - for (const [name, field] of Object.entries(noteInfo.fields)) { - fields[name] = field?.value || ''; - } - return fields; - } - async buildFieldGroupingPreview( keepNoteId: number, deleteNoteId: number, @@ -1498,7 +1091,7 @@ export class AnkiIntegration { return; } const keepNoteInfo = keepNotesInfo[0]!; - const mergedFields = await this.computeFieldGroupingMergedFields( + const mergedFields = await this.fieldGroupingMergeCollaborator.computeFieldGroupingMergedFields( keepNoteId, deleteNoteId, keepNoteInfo, diff --git a/src/anki-integration/field-grouping-merge.ts b/src/anki-integration/field-grouping-merge.ts new file mode 100644 index 0000000..06bff96 --- /dev/null +++ b/src/anki-integration/field-grouping-merge.ts @@ -0,0 +1,461 @@ +import { AnkiConnectConfig } from '../types'; + +interface FieldGroupingMergeMedia { + audioField?: string; + audioValue?: string; + imageField?: string; + imageValue?: string; + miscInfoValue?: string; +} + +export interface FieldGroupingMergeNoteInfo { + noteId: number; + fields: Record; +} + +interface FieldGroupingMergeDeps { + getConfig: () => AnkiConnectConfig; + getEffectiveSentenceCardConfig: () => { + sentenceField: string; + audioField: string; + }; + getCurrentSubtitleText: () => string | undefined; + resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; + resolveNoteFieldName: ( + noteInfo: FieldGroupingMergeNoteInfo, + preferredName?: string, + ) => string | null; + extractFields: (fields: Record) => Record; + processSentence: (mpvSentence: string, noteFields: Record) => string; + generateMediaForMerge: () => Promise; + warnFieldParseOnce: (fieldName: string, reason: string, detail?: string) => void; +} + +export class FieldGroupingMergeCollaborator { + private readonly strictGroupingFieldDefaults = new Set([ + 'picture', + 'sentence', + 'sentenceaudio', + 'sentencefurigana', + 'miscinfo', + ]); + + constructor(private readonly deps: FieldGroupingMergeDeps) {} + + getGroupableFieldNames(): string[] { + const config = this.deps.getConfig(); + const fields: string[] = []; + fields.push('Sentence'); + fields.push('SentenceAudio'); + fields.push('Picture'); + if (config.fields?.image) fields.push(config.fields?.image); + if (config.fields?.sentence) fields.push(config.fields?.sentence); + if (config.fields?.audio && config.fields?.audio.toLowerCase() !== 'expressionaudio') { + fields.push(config.fields?.audio); + } + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const sentenceAudioField = sentenceCardConfig.audioField; + if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField); + if (config.fields?.miscInfo) fields.push(config.fields?.miscInfo); + fields.push('SentenceFurigana'); + return fields; + } + + getNoteFieldMap(noteInfo: FieldGroupingMergeNoteInfo): Record { + const fields: Record = {}; + for (const [name, field] of Object.entries(noteInfo.fields)) { + fields[name] = field?.value || ''; + } + return fields; + } + + async computeFieldGroupingMergedFields( + keepNoteId: number, + deleteNoteId: number, + keepNoteInfo: FieldGroupingMergeNoteInfo, + deleteNoteInfo: FieldGroupingMergeNoteInfo, + includeGeneratedMedia: boolean, + ): Promise> { + const config = this.deps.getConfig(); + const groupableFields = this.getGroupableFieldNames(); + const keepFieldNames = Object.keys(keepNoteInfo.fields); + const sourceFields: Record = {}; + const resolvedKeepFieldByPreferred = new Map(); + for (const preferredFieldName of groupableFields) { + sourceFields[preferredFieldName] = this.getResolvedFieldValue( + deleteNoteInfo, + preferredFieldName, + ); + const keepResolved = this.deps.resolveFieldName(keepFieldNames, preferredFieldName); + if (keepResolved) { + resolvedKeepFieldByPreferred.set(preferredFieldName, keepResolved); + } + } + + if (!sourceFields['SentenceFurigana'] && sourceFields['Sentence']) { + sourceFields['SentenceFurigana'] = sourceFields['Sentence']; + } + if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) { + sourceFields['Sentence'] = sourceFields['SentenceFurigana']; + } + if (!sourceFields['Expression'] && sourceFields['Word']) { + sourceFields['Expression'] = sourceFields['Word']; + } + if (!sourceFields['Word'] && sourceFields['Expression']) { + sourceFields['Word'] = sourceFields['Expression']; + } + if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) { + sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio']; + } + if (!sourceFields['ExpressionAudio'] && sourceFields['SentenceAudio']) { + sourceFields['ExpressionAudio'] = sourceFields['SentenceAudio']; + } + + if ( + config.fields?.sentence && + !sourceFields[config.fields?.sentence] && + this.deps.getCurrentSubtitleText() + ) { + const deleteFields = this.deps.extractFields(deleteNoteInfo.fields); + sourceFields[config.fields?.sentence] = this.deps.processSentence( + this.deps.getCurrentSubtitleText()!, + deleteFields, + ); + } + + if (includeGeneratedMedia) { + const media = await this.deps.generateMediaForMerge(); + if (media.audioField && media.audioValue && !sourceFields[media.audioField]) { + sourceFields[media.audioField] = media.audioValue; + } + if (media.imageField && media.imageValue && !sourceFields[media.imageField]) { + sourceFields[media.imageField] = media.imageValue; + } + if ( + config.fields?.miscInfo && + media.miscInfoValue && + !sourceFields[config.fields?.miscInfo] + ) { + sourceFields[config.fields?.miscInfo] = media.miscInfoValue; + } + } + + const mergedFields: Record = {}; + for (const preferredFieldName of groupableFields) { + const keepFieldName = resolvedKeepFieldByPreferred.get(preferredFieldName); + if (!keepFieldName) continue; + + const keepFieldNormalized = keepFieldName.toLowerCase(); + if ( + keepFieldNormalized === 'expression' || + keepFieldNormalized === 'expressionfurigana' || + keepFieldNormalized === 'expressionreading' || + keepFieldNormalized === 'expressionaudio' + ) { + continue; + } + + const existingValue = keepNoteInfo.fields[keepFieldName]?.value || ''; + const newValue = sourceFields[preferredFieldName] || ''; + const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName); + if (!existingValue.trim() && !newValue.trim()) continue; + + if (isStrictField) { + mergedFields[keepFieldName] = this.applyFieldGrouping( + existingValue, + newValue, + keepNoteId, + deleteNoteId, + keepFieldName, + ); + } else if (existingValue.trim() && newValue.trim()) { + mergedFields[keepFieldName] = this.applyFieldGrouping( + existingValue, + newValue, + keepNoteId, + deleteNoteId, + keepFieldName, + ); + } else { + if (!newValue.trim()) continue; + mergedFields[keepFieldName] = newValue; + } + } + + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const resolvedSentenceAudioField = this.deps.resolveFieldName( + keepFieldNames, + sentenceCardConfig.audioField || 'SentenceAudio', + ); + const resolvedExpressionAudioField = this.deps.resolveFieldName( + keepFieldNames, + config.fields?.audio || 'ExpressionAudio', + ); + if ( + resolvedSentenceAudioField && + resolvedExpressionAudioField && + resolvedExpressionAudioField !== resolvedSentenceAudioField + ) { + const mergedSentenceAudioValue = + mergedFields[resolvedSentenceAudioField] || + keepNoteInfo.fields[resolvedSentenceAudioField]?.value || + ''; + if (mergedSentenceAudioValue.trim()) { + mergedFields[resolvedExpressionAudioField] = mergedSentenceAudioValue; + } + } + + return mergedFields; + } + + private getResolvedFieldValue( + noteInfo: FieldGroupingMergeNoteInfo, + preferredFieldName?: string, + ): string { + if (!preferredFieldName) return ''; + const resolved = this.deps.resolveNoteFieldName(noteInfo, preferredFieldName); + if (!resolved) return ''; + return noteInfo.fields[resolved]?.value || ''; + } + + private extractUngroupedValue(value: string): string { + const groupedSpanRegex = /[\s\S]*?<\/span>/gi; + const ungrouped = value.replace(groupedSpanRegex, '').trim(); + if (ungrouped) return ungrouped; + return value.trim(); + } + + private extractLastSoundTag(value: string): string { + const matches = value.match(/\[sound:[^\]]+\]/g); + if (!matches || matches.length === 0) return ''; + return matches[matches.length - 1]!; + } + + private extractLastImageTag(value: string): string { + const matches = value.match(/]*>/gi); + if (!matches || matches.length === 0) return ''; + return matches[matches.length - 1]!; + } + + private extractImageTags(value: string): string[] { + const matches = value.match(/]*>/gi); + return matches || []; + } + + private ensureImageGroupId(imageTag: string, groupId: number): string { + if (!imageTag) return ''; + if (/data-group-id=/i.test(imageTag)) { + return imageTag.replace(/data-group-id="[^"]*"/i, `data-group-id="${groupId}"`); + } + return imageTag.replace(/]*data-group-id="([^"]*)"[^>]*>/gi; + let malformed; + while ((malformed = malformedIdRegex.exec(value)) !== null) { + const rawId = malformed[1]; + const groupId = Number(rawId); + if (!Number.isFinite(groupId) || groupId <= 0) { + this.deps.warnFieldParseOnce(fieldName, 'invalid-group-id', rawId); + } + } + + const spanRegex = /]*>([\s\S]*?)<\/span>/gi; + let match; + while ((match = spanRegex.exec(value)) !== null) { + const groupId = Number(match[1]); + if (!Number.isFinite(groupId) || groupId <= 0) continue; + const content = this.normalizeStrictGroupedValue(match[2] || '', fieldName); + if (!content) { + this.deps.warnFieldParseOnce(fieldName, 'empty-group-content'); + continue; + } + entries.push({ groupId, content }); + } + if (entries.length === 0 && /(); + for (const entry of entries) { + const key = `${entry.groupId}::${entry.content}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(entry); + } + return unique; + } + + private parsePictureEntries( + value: string, + fallbackGroupId: number, + ): { groupId: number; tag: string }[] { + const tags = this.extractImageTags(value); + const result: { groupId: number; tag: string }[] = []; + for (const tag of tags) { + const idMatch = tag.match(/data-group-id="(\d+)"/i); + let groupId = fallbackGroupId; + if (idMatch) { + const parsed = Number(idMatch[1]); + if (!Number.isFinite(parsed) || parsed <= 0) { + this.deps.warnFieldParseOnce('Picture', 'invalid-group-id', idMatch[1]); + } else { + groupId = parsed; + } + } + const normalizedTag = this.ensureImageGroupId(tag, groupId); + if (!normalizedTag) { + this.deps.warnFieldParseOnce('Picture', 'empty-image-tag'); + continue; + } + result.push({ groupId, tag: normalizedTag }); + } + return result; + } + + private normalizeStrictGroupedValue(value: string, fieldName: string): string { + const ungrouped = this.extractUngroupedValue(value); + if (!ungrouped) return ''; + + const normalizedField = fieldName.toLowerCase(); + if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') { + const lastSoundTag = this.extractLastSoundTag(ungrouped); + if (!lastSoundTag) { + this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); + } + return lastSoundTag || ungrouped; + } + + if (normalizedField === 'picture') { + const lastImageTag = this.extractLastImageTag(ungrouped); + if (!lastImageTag) { + this.deps.warnFieldParseOnce(fieldName, 'missing-image-tag'); + } + return lastImageTag || ungrouped; + } + + return ungrouped; + } + + private getStrictSpanGroupingFields(): Set { + const strictFields = new Set(this.strictGroupingFieldDefaults); + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + strictFields.add((sentenceCardConfig.sentenceField || 'sentence').toLowerCase()); + strictFields.add((sentenceCardConfig.audioField || 'sentenceaudio').toLowerCase()); + const config = this.deps.getConfig(); + if (config.fields?.image) strictFields.add(config.fields.image.toLowerCase()); + if (config.fields?.miscInfo) strictFields.add(config.fields.miscInfo.toLowerCase()); + return strictFields; + } + + private shouldUseStrictSpanGrouping(fieldName: string): boolean { + const normalized = fieldName.toLowerCase(); + return this.getStrictSpanGroupingFields().has(normalized); + } + + private applyFieldGrouping( + existingValue: string, + newValue: string, + keepGroupId: number, + sourceGroupId: number, + fieldName: string, + ): string { + if (this.shouldUseStrictSpanGrouping(fieldName)) { + if (fieldName.toLowerCase() === 'picture') { + const keepEntries = this.parsePictureEntries(existingValue, keepGroupId); + const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId); + if (keepEntries.length === 0 && sourceEntries.length === 0) { + return existingValue || newValue; + } + const mergedTags = keepEntries.map((entry) => + this.ensureImageGroupId(entry.tag, entry.groupId), + ); + const seen = new Set(mergedTags); + for (const entry of sourceEntries) { + const normalized = this.ensureImageGroupId(entry.tag, entry.groupId); + if (seen.has(normalized)) continue; + seen.add(normalized); + mergedTags.push(normalized); + } + return mergedTags.join(''); + } + + const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName); + const sourceEntries = this.parseStrictEntries(newValue, sourceGroupId, fieldName); + if (keepEntries.length === 0 && sourceEntries.length === 0) { + return existingValue || newValue; + } + if (sourceEntries.length === 0) { + return keepEntries + .map((entry) => `${entry.content}`) + .join(''); + } + const merged = [...keepEntries]; + const seen = new Set(keepEntries.map((entry) => `${entry.groupId}::${entry.content}`)); + for (const entry of sourceEntries) { + const key = `${entry.groupId}::${entry.content}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(entry); + } + if (merged.length === 0) return existingValue; + return merged + .map((entry) => `${entry.content}`) + .join(''); + } + + if (!existingValue.trim()) return newValue; + if (!newValue.trim()) return existingValue; + + const hasGroups = /data-group-id/.test(existingValue); + + if (!hasGroups) { + return `${existingValue}\n` + newValue; + } + + const groupedSpanRegex = /[\s\S]*?<\/span>/g; + let lastEnd = 0; + let result = ''; + let match; + + while ((match = groupedSpanRegex.exec(existingValue)) !== null) { + const before = existingValue.slice(lastEnd, match.index); + if (before.trim()) { + result += `${before.trim()}\n`; + } + result += match[0] + '\n'; + lastEnd = match.index + match[0].length; + } + + const after = existingValue.slice(lastEnd); + if (after.trim()) { + result += `\n${after.trim()}`; + } + + return result + '\n' + newValue; + } +} diff --git a/src/config/config.test.ts b/src/config/config.test.ts index ed18e69..ba804a8 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -370,6 +370,88 @@ test('reloadConfigStrict rejects invalid json and preserves previous config', () assert.equal(service.getConfig().logging.level, 'error'); }); +test('prefers config.jsonc over config.json when both exist', () => { + const dir = makeTempDir(); + const jsonPath = path.join(dir, 'config.json'); + const jsoncPath = path.join(dir, 'config.jsonc'); + fs.writeFileSync(jsonPath, JSON.stringify({ logging: { level: 'error' } }, null, 2)); + fs.writeFileSync( + jsoncPath, + `{ + "logging": { + "level": "warn" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + assert.equal(service.getConfig().logging.level, 'warn'); + assert.equal(service.getConfigPath(), jsoncPath); +}); + +test('reloadConfigStrict parse failure does not mutate raw config or warnings', () => { + const dir = makeTempDir(); + const configPath = path.join(dir, 'config.jsonc'); + fs.writeFileSync( + configPath, + `{ + "logging": { + "level": "warn" + }, + "websocket": { + "port": "bad" + } + }`, + ); + + const service = new ConfigService(dir); + const beforePath = service.getConfigPath(); + const beforeConfig = service.getConfig(); + const beforeRaw = service.getRawConfig(); + const beforeWarnings = service.getWarnings(); + + fs.writeFileSync(configPath, '{"logging":'); + + const result = service.reloadConfigStrict(); + assert.equal(result.ok, false); + assert.equal(service.getConfigPath(), beforePath); + assert.deepEqual(service.getConfig(), beforeConfig); + assert.deepEqual(service.getRawConfig(), beforeRaw); + assert.deepEqual(service.getWarnings(), beforeWarnings); +}); + +test('warning emission order is deterministic across reloads', () => { + const dir = makeTempDir(); + const configPath = path.join(dir, 'config.jsonc'); + fs.writeFileSync( + configPath, + `{ + "unknownFeature": true, + "websocket": { + "enabled": "sometimes", + "port": -1 + }, + "logging": { + "level": "trace" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const firstWarnings = service.getWarnings(); + + service.reloadConfig(); + const secondWarnings = service.getWarnings(); + + assert.deepEqual(secondWarnings, firstWarnings); + assert.deepEqual( + firstWarnings.map((warning) => warning.path), + ['unknownFeature', 'websocket.enabled', 'websocket.port', 'logging.level'], + ); +}); + test('accepts valid logging.level', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/load.ts b/src/config/load.ts new file mode 100644 index 0000000..d8a4bc1 --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,65 @@ +import * as fs from 'fs'; +import { RawConfig } from '../types'; +import { parseConfigContent } from './parse'; + +export interface ConfigPaths { + configDir: string; + configFileJsonc: string; + configFileJson: string; +} + +export interface LoadResult { + config: RawConfig; + path: string; +} + +export type StrictLoadResult = + | (LoadResult & { ok: true }) + | { + ok: false; + error: string; + path: string; + }; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function resolveExistingConfigPath(paths: ConfigPaths): string { + if (fs.existsSync(paths.configFileJsonc)) { + return paths.configFileJsonc; + } + if (fs.existsSync(paths.configFileJson)) { + return paths.configFileJson; + } + return paths.configFileJsonc; +} + +export function loadRawConfigStrict(paths: ConfigPaths): StrictLoadResult { + const configPath = resolveExistingConfigPath(paths); + + if (!fs.existsSync(configPath)) { + return { ok: true, config: {}, path: configPath }; + } + + try { + const data = fs.readFileSync(configPath, 'utf-8'); + const parsed = parseConfigContent(configPath, data); + return { + ok: true, + config: isObject(parsed) ? (parsed as RawConfig) : {}, + path: configPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown parse error'; + return { ok: false, error: message, path: configPath }; + } +} + +export function loadRawConfig(paths: ConfigPaths): LoadResult { + const strictResult = loadRawConfigStrict(paths); + if (strictResult.ok) { + return strictResult; + } + return { config: {}, path: strictResult.path }; +} diff --git a/src/config/parse.ts b/src/config/parse.ts new file mode 100644 index 0000000..2bc063a --- /dev/null +++ b/src/config/parse.ts @@ -0,0 +1,17 @@ +import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; + +export function parseConfigContent(configPath: string, data: string): unknown { + if (!configPath.endsWith('.jsonc')) { + return JSON.parse(data); + } + + const errors: ParseError[] = []; + const result = parseJsonc(data, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0) { + throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`); + } + return result; +} diff --git a/src/config/resolve.ts b/src/config/resolve.ts new file mode 100644 index 0000000..a713760 --- /dev/null +++ b/src/config/resolve.ts @@ -0,0 +1,1414 @@ +import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types'; +import { DEFAULT_CONFIG, deepCloneConfig } from './definitions'; +import { createWarningCollector } from './warnings'; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +function asColor(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const text = value.trim(); + return hexColorPattern.test(text) ? text : undefined; +} + +function asFrequencyBandedColors( + value: unknown, +): [string, string, string, string, string] | undefined { + if (!Array.isArray(value) || value.length !== 5) { + return undefined; + } + + const colors = value.map((item) => asColor(item)); + if (colors.some((color) => color === undefined)) { + return undefined; + } + + return colors as [string, string, string, string, string]; +} + +export function resolveConfig(raw: RawConfig): { + resolved: ResolvedConfig; + warnings: ConfigValidationWarning[]; +} { + const resolved = deepCloneConfig(DEFAULT_CONFIG); + const { warnings, warn } = createWarningCollector(); + + const src = isObject(raw) ? raw : {}; + const knownTopLevelKeys = new Set(Object.keys(resolved)); + for (const key of Object.keys(src)) { + if (!knownTopLevelKeys.has(key)) { + warn(key, src[key], undefined, 'Unknown top-level config key; ignored.'); + } + } + + if (isObject(src.texthooker)) { + const openBrowser = asBoolean(src.texthooker.openBrowser); + if (openBrowser !== undefined) { + resolved.texthooker.openBrowser = openBrowser; + } else if (src.texthooker.openBrowser !== undefined) { + warn( + 'texthooker.openBrowser', + src.texthooker.openBrowser, + resolved.texthooker.openBrowser, + 'Expected boolean.', + ); + } + } + + if (isObject(src.websocket)) { + const enabled = src.websocket.enabled; + if (enabled === 'auto' || enabled === true || enabled === false) { + resolved.websocket.enabled = enabled; + } else if (enabled !== undefined) { + warn( + 'websocket.enabled', + enabled, + resolved.websocket.enabled, + "Expected true, false, or 'auto'.", + ); + } + + const port = asNumber(src.websocket.port); + if (port !== undefined && port > 0 && port <= 65535) { + resolved.websocket.port = Math.floor(port); + } else if (src.websocket.port !== undefined) { + warn( + 'websocket.port', + src.websocket.port, + resolved.websocket.port, + 'Expected integer between 1 and 65535.', + ); + } + } + + if (isObject(src.logging)) { + const logLevel = asString(src.logging.level); + if ( + logLevel === 'debug' || + logLevel === 'info' || + logLevel === 'warn' || + logLevel === 'error' + ) { + resolved.logging.level = logLevel; + } else if (src.logging.level !== undefined) { + warn( + 'logging.level', + src.logging.level, + resolved.logging.level, + 'Expected debug, info, warn, or error.', + ); + } + } + + if (Array.isArray(src.keybindings)) { + resolved.keybindings = src.keybindings.filter( + (entry): entry is { key: string; command: (string | number)[] | null } => { + if (!isObject(entry)) return false; + if (typeof entry.key !== 'string') return false; + if (entry.command === null) return true; + return Array.isArray(entry.command); + }, + ); + } + + if (isObject(src.shortcuts)) { + const shortcutKeys = [ + 'toggleVisibleOverlayGlobal', + 'toggleInvisibleOverlayGlobal', + 'copySubtitle', + 'copySubtitleMultiple', + 'updateLastCardFromClipboard', + 'triggerFieldGrouping', + 'triggerSubsync', + 'mineSentence', + 'mineSentenceMultiple', + 'toggleSecondarySub', + 'markAudioCard', + 'openRuntimeOptions', + 'openJimaku', + ] as const; + + for (const key of shortcutKeys) { + const value = src.shortcuts[key]; + if (typeof value === 'string' || value === null) { + resolved.shortcuts[key] = value as (typeof resolved.shortcuts)[typeof key]; + } else if (value !== undefined) { + warn(`shortcuts.${key}`, value, resolved.shortcuts[key], 'Expected string or null.'); + } + } + + const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs); + if (timeout !== undefined && timeout > 0) { + resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout); + } else if (src.shortcuts.multiCopyTimeoutMs !== undefined) { + warn( + 'shortcuts.multiCopyTimeoutMs', + src.shortcuts.multiCopyTimeoutMs, + resolved.shortcuts.multiCopyTimeoutMs, + 'Expected positive number.', + ); + } + } + + if (isObject(src.invisibleOverlay)) { + const startupVisibility = src.invisibleOverlay.startupVisibility; + if ( + startupVisibility === 'platform-default' || + startupVisibility === 'visible' || + startupVisibility === 'hidden' + ) { + resolved.invisibleOverlay.startupVisibility = startupVisibility; + } else if (startupVisibility !== undefined) { + warn( + 'invisibleOverlay.startupVisibility', + startupVisibility, + resolved.invisibleOverlay.startupVisibility, + 'Expected platform-default, visible, or hidden.', + ); + } + } + + if (isObject(src.secondarySub)) { + if (Array.isArray(src.secondarySub.secondarySubLanguages)) { + resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter( + (item): item is string => typeof item === 'string', + ); + } + const autoLoad = asBoolean(src.secondarySub.autoLoadSecondarySub); + if (autoLoad !== undefined) { + resolved.secondarySub.autoLoadSecondarySub = autoLoad; + } + const defaultMode = src.secondarySub.defaultMode; + if (defaultMode === 'hidden' || defaultMode === 'visible' || defaultMode === 'hover') { + resolved.secondarySub.defaultMode = defaultMode; + } else if (defaultMode !== undefined) { + warn( + 'secondarySub.defaultMode', + defaultMode, + resolved.secondarySub.defaultMode, + 'Expected hidden, visible, or hover.', + ); + } + } + + if (isObject(src.subsync)) { + const mode = src.subsync.defaultMode; + if (mode === 'auto' || mode === 'manual') { + resolved.subsync.defaultMode = mode; + } else if (mode !== undefined) { + warn('subsync.defaultMode', mode, resolved.subsync.defaultMode, 'Expected auto or manual.'); + } + + const alass = asString(src.subsync.alass_path); + if (alass !== undefined) resolved.subsync.alass_path = alass; + const ffsubsync = asString(src.subsync.ffsubsync_path); + if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync; + const ffmpeg = asString(src.subsync.ffmpeg_path); + if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg; + } + + if (isObject(src.subtitlePosition)) { + const y = asNumber(src.subtitlePosition.yPercent); + if (y !== undefined) { + resolved.subtitlePosition.yPercent = y; + } + } + + if (isObject(src.jimaku)) { + const apiKey = asString(src.jimaku.apiKey); + if (apiKey !== undefined) resolved.jimaku.apiKey = apiKey; + const apiKeyCommand = asString(src.jimaku.apiKeyCommand); + if (apiKeyCommand !== undefined) resolved.jimaku.apiKeyCommand = apiKeyCommand; + const apiBaseUrl = asString(src.jimaku.apiBaseUrl); + if (apiBaseUrl !== undefined) resolved.jimaku.apiBaseUrl = apiBaseUrl; + + const lang = src.jimaku.languagePreference; + if (lang === 'ja' || lang === 'en' || lang === 'none') { + resolved.jimaku.languagePreference = lang; + } else if (lang !== undefined) { + warn( + 'jimaku.languagePreference', + lang, + resolved.jimaku.languagePreference, + 'Expected ja, en, or none.', + ); + } + + const maxEntryResults = asNumber(src.jimaku.maxEntryResults); + if (maxEntryResults !== undefined && maxEntryResults > 0) { + resolved.jimaku.maxEntryResults = Math.floor(maxEntryResults); + } else if (src.jimaku.maxEntryResults !== undefined) { + warn( + 'jimaku.maxEntryResults', + src.jimaku.maxEntryResults, + resolved.jimaku.maxEntryResults, + 'Expected positive number.', + ); + } + } + + if (isObject(src.youtubeSubgen)) { + const mode = src.youtubeSubgen.mode; + if (mode === 'automatic' || mode === 'preprocess' || mode === 'off') { + resolved.youtubeSubgen.mode = mode; + } else if (mode !== undefined) { + warn( + 'youtubeSubgen.mode', + mode, + resolved.youtubeSubgen.mode, + 'Expected automatic, preprocess, or off.', + ); + } + + const whisperBin = asString(src.youtubeSubgen.whisperBin); + if (whisperBin !== undefined) { + resolved.youtubeSubgen.whisperBin = whisperBin; + } else if (src.youtubeSubgen.whisperBin !== undefined) { + warn( + 'youtubeSubgen.whisperBin', + src.youtubeSubgen.whisperBin, + resolved.youtubeSubgen.whisperBin, + 'Expected string.', + ); + } + + const whisperModel = asString(src.youtubeSubgen.whisperModel); + if (whisperModel !== undefined) { + resolved.youtubeSubgen.whisperModel = whisperModel; + } else if (src.youtubeSubgen.whisperModel !== undefined) { + warn( + 'youtubeSubgen.whisperModel', + src.youtubeSubgen.whisperModel, + resolved.youtubeSubgen.whisperModel, + 'Expected string.', + ); + } + + if (Array.isArray(src.youtubeSubgen.primarySubLanguages)) { + resolved.youtubeSubgen.primarySubLanguages = src.youtubeSubgen.primarySubLanguages.filter( + (item): item is string => typeof item === 'string', + ); + } else if (src.youtubeSubgen.primarySubLanguages !== undefined) { + warn( + 'youtubeSubgen.primarySubLanguages', + src.youtubeSubgen.primarySubLanguages, + resolved.youtubeSubgen.primarySubLanguages, + 'Expected string array.', + ); + } + } + + if (isObject(src.anilist)) { + const enabled = asBoolean(src.anilist.enabled); + if (enabled !== undefined) { + resolved.anilist.enabled = enabled; + } else if (src.anilist.enabled !== undefined) { + warn('anilist.enabled', src.anilist.enabled, resolved.anilist.enabled, 'Expected boolean.'); + } + + const accessToken = asString(src.anilist.accessToken); + if (accessToken !== undefined) { + resolved.anilist.accessToken = accessToken; + } else if (src.anilist.accessToken !== undefined) { + warn( + 'anilist.accessToken', + src.anilist.accessToken, + resolved.anilist.accessToken, + 'Expected string.', + ); + } + } + + if (isObject(src.jellyfin)) { + const enabled = asBoolean(src.jellyfin.enabled); + if (enabled !== undefined) { + resolved.jellyfin.enabled = enabled; + } else if (src.jellyfin.enabled !== undefined) { + warn( + 'jellyfin.enabled', + src.jellyfin.enabled, + resolved.jellyfin.enabled, + 'Expected boolean.', + ); + } + + const stringKeys = [ + 'serverUrl', + 'username', + 'accessToken', + 'userId', + 'deviceId', + 'clientName', + 'clientVersion', + 'defaultLibraryId', + 'iconCacheDir', + 'transcodeVideoCodec', + ] as const; + for (const key of stringKeys) { + const value = asString(src.jellyfin[key]); + if (value !== undefined) { + resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key]; + } else if (src.jellyfin[key] !== undefined) { + warn(`jellyfin.${key}`, src.jellyfin[key], resolved.jellyfin[key], 'Expected string.'); + } + } + + const booleanKeys = [ + 'remoteControlEnabled', + 'remoteControlAutoConnect', + 'autoAnnounce', + 'directPlayPreferred', + 'pullPictures', + ] as const; + for (const key of booleanKeys) { + const value = asBoolean(src.jellyfin[key]); + if (value !== undefined) { + resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key]; + } else if (src.jellyfin[key] !== undefined) { + warn(`jellyfin.${key}`, src.jellyfin[key], resolved.jellyfin[key], 'Expected boolean.'); + } + } + + if (Array.isArray(src.jellyfin.directPlayContainers)) { + resolved.jellyfin.directPlayContainers = src.jellyfin.directPlayContainers + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim().toLowerCase()) + .filter((item) => item.length > 0); + } else if (src.jellyfin.directPlayContainers !== undefined) { + warn( + 'jellyfin.directPlayContainers', + src.jellyfin.directPlayContainers, + resolved.jellyfin.directPlayContainers, + 'Expected string array.', + ); + } + } + + if (asBoolean(src.auto_start_overlay) !== undefined) { + resolved.auto_start_overlay = src.auto_start_overlay as boolean; + } + + if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) { + resolved.bind_visible_overlay_to_mpv_sub_visibility = + src.bind_visible_overlay_to_mpv_sub_visibility as boolean; + } else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) { + warn( + 'bind_visible_overlay_to_mpv_sub_visibility', + src.bind_visible_overlay_to_mpv_sub_visibility, + resolved.bind_visible_overlay_to_mpv_sub_visibility, + 'Expected boolean.', + ); + } + + if (isObject(src.immersionTracking)) { + const enabled = asBoolean(src.immersionTracking.enabled); + if (enabled !== undefined) { + resolved.immersionTracking.enabled = enabled; + } else if (src.immersionTracking.enabled !== undefined) { + warn( + 'immersionTracking.enabled', + src.immersionTracking.enabled, + resolved.immersionTracking.enabled, + 'Expected boolean.', + ); + } + + const dbPath = asString(src.immersionTracking.dbPath); + if (dbPath !== undefined) { + resolved.immersionTracking.dbPath = dbPath; + } else if (src.immersionTracking.dbPath !== undefined) { + warn( + 'immersionTracking.dbPath', + src.immersionTracking.dbPath, + resolved.immersionTracking.dbPath, + 'Expected string.', + ); + } + + const batchSize = asNumber(src.immersionTracking.batchSize); + if (batchSize !== undefined && batchSize >= 1 && batchSize <= 10_000) { + resolved.immersionTracking.batchSize = Math.floor(batchSize); + } else if (src.immersionTracking.batchSize !== undefined) { + warn( + 'immersionTracking.batchSize', + src.immersionTracking.batchSize, + resolved.immersionTracking.batchSize, + 'Expected integer between 1 and 10000.', + ); + } + + const flushIntervalMs = asNumber(src.immersionTracking.flushIntervalMs); + if (flushIntervalMs !== undefined && flushIntervalMs >= 50 && flushIntervalMs <= 60_000) { + resolved.immersionTracking.flushIntervalMs = Math.floor(flushIntervalMs); + } else if (src.immersionTracking.flushIntervalMs !== undefined) { + warn( + 'immersionTracking.flushIntervalMs', + src.immersionTracking.flushIntervalMs, + resolved.immersionTracking.flushIntervalMs, + 'Expected integer between 50 and 60000.', + ); + } + + const queueCap = asNumber(src.immersionTracking.queueCap); + if (queueCap !== undefined && queueCap >= 100 && queueCap <= 100_000) { + resolved.immersionTracking.queueCap = Math.floor(queueCap); + } else if (src.immersionTracking.queueCap !== undefined) { + warn( + 'immersionTracking.queueCap', + src.immersionTracking.queueCap, + resolved.immersionTracking.queueCap, + 'Expected integer between 100 and 100000.', + ); + } + + const payloadCapBytes = asNumber(src.immersionTracking.payloadCapBytes); + if (payloadCapBytes !== undefined && payloadCapBytes >= 64 && payloadCapBytes <= 8192) { + resolved.immersionTracking.payloadCapBytes = Math.floor(payloadCapBytes); + } else if (src.immersionTracking.payloadCapBytes !== undefined) { + warn( + 'immersionTracking.payloadCapBytes', + src.immersionTracking.payloadCapBytes, + resolved.immersionTracking.payloadCapBytes, + 'Expected integer between 64 and 8192.', + ); + } + + const maintenanceIntervalMs = asNumber(src.immersionTracking.maintenanceIntervalMs); + if ( + maintenanceIntervalMs !== undefined && + maintenanceIntervalMs >= 60_000 && + maintenanceIntervalMs <= 7 * 24 * 60 * 60 * 1000 + ) { + resolved.immersionTracking.maintenanceIntervalMs = Math.floor(maintenanceIntervalMs); + } else if (src.immersionTracking.maintenanceIntervalMs !== undefined) { + warn( + 'immersionTracking.maintenanceIntervalMs', + src.immersionTracking.maintenanceIntervalMs, + resolved.immersionTracking.maintenanceIntervalMs, + 'Expected integer between 60000 and 604800000.', + ); + } + + if (isObject(src.immersionTracking.retention)) { + const eventsDays = asNumber(src.immersionTracking.retention.eventsDays); + if (eventsDays !== undefined && eventsDays >= 1 && eventsDays <= 3650) { + resolved.immersionTracking.retention.eventsDays = Math.floor(eventsDays); + } else if (src.immersionTracking.retention.eventsDays !== undefined) { + warn( + 'immersionTracking.retention.eventsDays', + src.immersionTracking.retention.eventsDays, + resolved.immersionTracking.retention.eventsDays, + 'Expected integer between 1 and 3650.', + ); + } + + const telemetryDays = asNumber(src.immersionTracking.retention.telemetryDays); + if (telemetryDays !== undefined && telemetryDays >= 1 && telemetryDays <= 3650) { + resolved.immersionTracking.retention.telemetryDays = Math.floor(telemetryDays); + } else if (src.immersionTracking.retention.telemetryDays !== undefined) { + warn( + 'immersionTracking.retention.telemetryDays', + src.immersionTracking.retention.telemetryDays, + resolved.immersionTracking.retention.telemetryDays, + 'Expected integer between 1 and 3650.', + ); + } + + const dailyRollupsDays = asNumber(src.immersionTracking.retention.dailyRollupsDays); + if (dailyRollupsDays !== undefined && dailyRollupsDays >= 1 && dailyRollupsDays <= 36500) { + resolved.immersionTracking.retention.dailyRollupsDays = Math.floor(dailyRollupsDays); + } else if (src.immersionTracking.retention.dailyRollupsDays !== undefined) { + warn( + 'immersionTracking.retention.dailyRollupsDays', + src.immersionTracking.retention.dailyRollupsDays, + resolved.immersionTracking.retention.dailyRollupsDays, + 'Expected integer between 1 and 36500.', + ); + } + + const monthlyRollupsDays = asNumber(src.immersionTracking.retention.monthlyRollupsDays); + if ( + monthlyRollupsDays !== undefined && + monthlyRollupsDays >= 1 && + monthlyRollupsDays <= 36500 + ) { + resolved.immersionTracking.retention.monthlyRollupsDays = Math.floor(monthlyRollupsDays); + } else if (src.immersionTracking.retention.monthlyRollupsDays !== undefined) { + warn( + 'immersionTracking.retention.monthlyRollupsDays', + src.immersionTracking.retention.monthlyRollupsDays, + resolved.immersionTracking.retention.monthlyRollupsDays, + 'Expected integer between 1 and 36500.', + ); + } + + const vacuumIntervalDays = asNumber(src.immersionTracking.retention.vacuumIntervalDays); + if ( + vacuumIntervalDays !== undefined && + vacuumIntervalDays >= 1 && + vacuumIntervalDays <= 3650 + ) { + resolved.immersionTracking.retention.vacuumIntervalDays = Math.floor(vacuumIntervalDays); + } else if (src.immersionTracking.retention.vacuumIntervalDays !== undefined) { + warn( + 'immersionTracking.retention.vacuumIntervalDays', + src.immersionTracking.retention.vacuumIntervalDays, + resolved.immersionTracking.retention.vacuumIntervalDays, + 'Expected integer between 1 and 3650.', + ); + } + } else if (src.immersionTracking.retention !== undefined) { + warn( + 'immersionTracking.retention', + src.immersionTracking.retention, + resolved.immersionTracking.retention, + 'Expected object.', + ); + } + } + + if (isObject(src.subtitleStyle)) { + const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; + const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; + resolved.subtitleStyle = { + ...resolved.subtitleStyle, + ...(src.subtitleStyle as ResolvedConfig['subtitleStyle']), + secondary: { + ...resolved.subtitleStyle.secondary, + ...(isObject(src.subtitleStyle.secondary) + ? (src.subtitleStyle.secondary as ResolvedConfig['subtitleStyle']['secondary']) + : {}), + }, + }; + + const enableJlpt = asBoolean((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt); + if (enableJlpt !== undefined) { + resolved.subtitleStyle.enableJlpt = enableJlpt; + } else if ((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined) { + resolved.subtitleStyle.enableJlpt = fallbackSubtitleStyleEnableJlpt; + warn( + 'subtitleStyle.enableJlpt', + (src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt, + resolved.subtitleStyle.enableJlpt, + 'Expected boolean.', + ); + } + + const preserveLineBreaks = asBoolean( + (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks, + ); + if (preserveLineBreaks !== undefined) { + resolved.subtitleStyle.preserveLineBreaks = preserveLineBreaks; + } else if ( + (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks !== undefined + ) { + resolved.subtitleStyle.preserveLineBreaks = fallbackSubtitleStylePreserveLineBreaks; + warn( + 'subtitleStyle.preserveLineBreaks', + (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks, + resolved.subtitleStyle.preserveLineBreaks, + 'Expected boolean.', + ); + } + + const frequencyDictionary = isObject( + (src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary, + ) + ? ((src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary as Record< + string, + unknown + >) + : {}; + const frequencyEnabled = asBoolean((frequencyDictionary as { enabled?: unknown }).enabled); + if (frequencyEnabled !== undefined) { + resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled; + } else if ((frequencyDictionary as { enabled?: unknown }).enabled !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.enabled', + (frequencyDictionary as { enabled?: unknown }).enabled, + resolved.subtitleStyle.frequencyDictionary.enabled, + 'Expected boolean.', + ); + } + + const sourcePath = asString((frequencyDictionary as { sourcePath?: unknown }).sourcePath); + if (sourcePath !== undefined) { + resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath; + } else if ((frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.sourcePath', + (frequencyDictionary as { sourcePath?: unknown }).sourcePath, + resolved.subtitleStyle.frequencyDictionary.sourcePath, + 'Expected string.', + ); + } + + const topX = asNumber((frequencyDictionary as { topX?: unknown }).topX); + if (topX !== undefined && Number.isInteger(topX) && topX > 0) { + resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX); + } else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.topX', + (frequencyDictionary as { topX?: unknown }).topX, + resolved.subtitleStyle.frequencyDictionary.topX, + 'Expected a positive integer.', + ); + } + + const frequencyMode = frequencyDictionary.mode; + if (frequencyMode === 'single' || frequencyMode === 'banded') { + resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode; + } else if (frequencyMode !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.mode', + frequencyDictionary.mode, + resolved.subtitleStyle.frequencyDictionary.mode, + "Expected 'single' or 'banded'.", + ); + } + + const singleColor = asColor((frequencyDictionary as { singleColor?: unknown }).singleColor); + if (singleColor !== undefined) { + resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor; + } else if ((frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.singleColor', + (frequencyDictionary as { singleColor?: unknown }).singleColor, + resolved.subtitleStyle.frequencyDictionary.singleColor, + 'Expected hex color.', + ); + } + + const bandedColors = asFrequencyBandedColors( + (frequencyDictionary as { bandedColors?: unknown }).bandedColors, + ); + if (bandedColors !== undefined) { + resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors; + } else if ((frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.bandedColors', + (frequencyDictionary as { bandedColors?: unknown }).bandedColors, + resolved.subtitleStyle.frequencyDictionary.bandedColors, + 'Expected an array of five hex colors.', + ); + } + } + + if (isObject(src.ankiConnect)) { + const ac = 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 aiSource = isObject(ac.ai) ? ac.ai : isObject(ac.openRouter) ? ac.openRouter : {}; + const legacyKeys = new Set([ + 'audioField', + 'imageField', + 'sentenceField', + 'miscInfoField', + 'miscInfoPattern', + 'generateAudio', + 'generateImage', + 'imageType', + 'imageFormat', + 'imageQuality', + 'imageMaxWidth', + 'imageMaxHeight', + 'animatedFps', + 'animatedMaxWidth', + 'animatedMaxHeight', + 'animatedCrf', + 'audioPadding', + 'fallbackDuration', + 'maxMediaDuration', + 'overwriteAudio', + 'overwriteImage', + 'mediaInsertMode', + 'highlightWord', + 'notificationType', + 'autoUpdateNewCards', + ]); + + if (ac.openRouter !== undefined) { + warn( + 'ankiConnect.openRouter', + ac.openRouter, + resolved.ankiConnect.ai, + 'Deprecated key; use ankiConnect.ai instead.', + ); + } + + const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } = + ac as Record; + const ankiConnectWithoutLegacy = Object.fromEntries( + Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)), + ); + + resolved.ankiConnect = { + ...resolved.ankiConnect, + ...(isObject(ankiConnectWithoutLegacy) + ? (ankiConnectWithoutLegacy as Partial) + : {}), + fields: { + ...resolved.ankiConnect.fields, + ...(isObject(ac.fields) ? (ac.fields as ResolvedConfig['ankiConnect']['fields']) : {}), + }, + ai: { + ...resolved.ankiConnect.ai, + ...(aiSource as ResolvedConfig['ankiConnect']['ai']), + }, + media: { + ...resolved.ankiConnect.media, + ...(isObject(ac.media) ? (ac.media as ResolvedConfig['ankiConnect']['media']) : {}), + }, + behavior: { + ...resolved.ankiConnect.behavior, + ...(isObject(ac.behavior) + ? (ac.behavior as ResolvedConfig['ankiConnect']['behavior']) + : {}), + }, + metadata: { + ...resolved.ankiConnect.metadata, + ...(isObject(ac.metadata) + ? (ac.metadata as ResolvedConfig['ankiConnect']['metadata']) + : {}), + }, + isLapis: { + ...resolved.ankiConnect.isLapis, + }, + isKiku: { + ...resolved.ankiConnect.isKiku, + ...(isObject(ac.isKiku) ? (ac.isKiku as ResolvedConfig['ankiConnect']['isKiku']) : {}), + }, + }; + + if (isObject(ac.isLapis)) { + const lapisEnabled = asBoolean(ac.isLapis.enabled); + if (lapisEnabled !== undefined) { + resolved.ankiConnect.isLapis.enabled = lapisEnabled; + } else if (ac.isLapis.enabled !== undefined) { + warn( + 'ankiConnect.isLapis.enabled', + ac.isLapis.enabled, + resolved.ankiConnect.isLapis.enabled, + 'Expected boolean.', + ); + } + + const sentenceCardModel = asString(ac.isLapis.sentenceCardModel); + if (sentenceCardModel !== undefined) { + resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel; + } else if (ac.isLapis.sentenceCardModel !== undefined) { + warn( + 'ankiConnect.isLapis.sentenceCardModel', + ac.isLapis.sentenceCardModel, + resolved.ankiConnect.isLapis.sentenceCardModel, + 'Expected string.', + ); + } + + if (ac.isLapis.sentenceCardSentenceField !== undefined) { + warn( + 'ankiConnect.isLapis.sentenceCardSentenceField', + ac.isLapis.sentenceCardSentenceField, + 'Sentence', + 'Deprecated key; sentence-card sentence field is fixed to Sentence.', + ); + } + + if (ac.isLapis.sentenceCardAudioField !== undefined) { + warn( + 'ankiConnect.isLapis.sentenceCardAudioField', + ac.isLapis.sentenceCardAudioField, + 'SentenceAudio', + 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.', + ); + } + } else if (ac.isLapis !== undefined) { + warn('ankiConnect.isLapis', ac.isLapis, resolved.ankiConnect.isLapis, 'Expected 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) { + resolved.ankiConnect.tags = [...new Set(normalizedTags)]; + } else { + resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + warn( + 'ankiConnect.tags', + ac.tags, + resolved.ankiConnect.tags, + 'Expected an array of non-empty strings.', + ); + } + } else if (ac.tags !== undefined) { + resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + warn('ankiConnect.tags', ac.tags, resolved.ankiConnect.tags, 'Expected an array of strings.'); + } + + const legacy = ac as Record; + const hasOwn = (obj: Record, key: string): boolean => + Object.prototype.hasOwnProperty.call(obj, key); + 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 asNotificationType = (value: unknown): 'osd' | 'system' | 'both' | 'none' | undefined => { + return value === 'osd' || value === 'system' || value === 'both' || value === 'none' + ? 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) { + warn(`ankiConnect.${key}`, value, fallback, message); + return; + } + apply(parsed); + }; + + if (!hasOwn(fields, 'audio')) { + mapLegacy( + 'audioField', + asString, + (value) => { + resolved.ankiConnect.fields.audio = value; + }, + resolved.ankiConnect.fields.audio, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'image')) { + mapLegacy( + 'imageField', + asString, + (value) => { + resolved.ankiConnect.fields.image = value; + }, + resolved.ankiConnect.fields.image, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'sentence')) { + mapLegacy( + 'sentenceField', + asString, + (value) => { + resolved.ankiConnect.fields.sentence = value; + }, + resolved.ankiConnect.fields.sentence, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'miscInfo')) { + mapLegacy( + 'miscInfoField', + asString, + (value) => { + resolved.ankiConnect.fields.miscInfo = value; + }, + resolved.ankiConnect.fields.miscInfo, + 'Expected string.', + ); + } + if (!hasOwn(metadata, 'pattern')) { + mapLegacy( + 'miscInfoPattern', + asString, + (value) => { + resolved.ankiConnect.metadata.pattern = value; + }, + resolved.ankiConnect.metadata.pattern, + 'Expected string.', + ); + } + if (!hasOwn(media, 'generateAudio')) { + mapLegacy( + 'generateAudio', + asBoolean, + (value) => { + resolved.ankiConnect.media.generateAudio = value; + }, + resolved.ankiConnect.media.generateAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'generateImage')) { + mapLegacy( + 'generateImage', + asBoolean, + (value) => { + resolved.ankiConnect.media.generateImage = value; + }, + resolved.ankiConnect.media.generateImage, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'imageType')) { + mapLegacy( + 'imageType', + asImageType, + (value) => { + resolved.ankiConnect.media.imageType = value; + }, + resolved.ankiConnect.media.imageType, + "Expected 'static' or 'avif'.", + ); + } + if (!hasOwn(media, 'imageFormat')) { + mapLegacy( + 'imageFormat', + asImageFormat, + (value) => { + resolved.ankiConnect.media.imageFormat = value; + }, + resolved.ankiConnect.media.imageFormat, + "Expected 'jpg', 'png', or 'webp'.", + ); + } + if (!hasOwn(media, 'imageQuality')) { + mapLegacy( + 'imageQuality', + (value) => asIntegerInRange(value, 1, 100), + (value) => { + resolved.ankiConnect.media.imageQuality = value; + }, + resolved.ankiConnect.media.imageQuality, + 'Expected integer between 1 and 100.', + ); + } + if (!hasOwn(media, 'imageMaxWidth')) { + mapLegacy( + 'imageMaxWidth', + asPositiveInteger, + (value) => { + resolved.ankiConnect.media.imageMaxWidth = value; + }, + resolved.ankiConnect.media.imageMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'imageMaxHeight')) { + mapLegacy( + 'imageMaxHeight', + asPositiveInteger, + (value) => { + resolved.ankiConnect.media.imageMaxHeight = value; + }, + resolved.ankiConnect.media.imageMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedFps')) { + mapLegacy( + 'animatedFps', + (value) => asIntegerInRange(value, 1, 60), + (value) => { + resolved.ankiConnect.media.animatedFps = value; + }, + resolved.ankiConnect.media.animatedFps, + 'Expected integer between 1 and 60.', + ); + } + if (!hasOwn(media, 'animatedMaxWidth')) { + mapLegacy( + 'animatedMaxWidth', + asPositiveInteger, + (value) => { + resolved.ankiConnect.media.animatedMaxWidth = value; + }, + resolved.ankiConnect.media.animatedMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedMaxHeight')) { + mapLegacy( + 'animatedMaxHeight', + asPositiveInteger, + (value) => { + resolved.ankiConnect.media.animatedMaxHeight = value; + }, + resolved.ankiConnect.media.animatedMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedCrf')) { + mapLegacy( + 'animatedCrf', + (value) => asIntegerInRange(value, 0, 63), + (value) => { + resolved.ankiConnect.media.animatedCrf = value; + }, + resolved.ankiConnect.media.animatedCrf, + 'Expected integer between 0 and 63.', + ); + } + if (!hasOwn(media, 'audioPadding')) { + mapLegacy( + 'audioPadding', + asNonNegativeNumber, + (value) => { + resolved.ankiConnect.media.audioPadding = value; + }, + resolved.ankiConnect.media.audioPadding, + 'Expected non-negative number.', + ); + } + if (!hasOwn(media, 'fallbackDuration')) { + mapLegacy( + 'fallbackDuration', + asPositiveNumber, + (value) => { + resolved.ankiConnect.media.fallbackDuration = value; + }, + resolved.ankiConnect.media.fallbackDuration, + 'Expected positive number.', + ); + } + if (!hasOwn(media, 'maxMediaDuration')) { + mapLegacy( + 'maxMediaDuration', + asNonNegativeNumber, + (value) => { + resolved.ankiConnect.media.maxMediaDuration = value; + }, + resolved.ankiConnect.media.maxMediaDuration, + 'Expected non-negative number.', + ); + } + if (!hasOwn(behavior, 'overwriteAudio')) { + mapLegacy( + 'overwriteAudio', + asBoolean, + (value) => { + resolved.ankiConnect.behavior.overwriteAudio = value; + }, + resolved.ankiConnect.behavior.overwriteAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'overwriteImage')) { + mapLegacy( + 'overwriteImage', + asBoolean, + (value) => { + resolved.ankiConnect.behavior.overwriteImage = value; + }, + resolved.ankiConnect.behavior.overwriteImage, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'mediaInsertMode')) { + mapLegacy( + 'mediaInsertMode', + asMediaInsertMode, + (value) => { + resolved.ankiConnect.behavior.mediaInsertMode = value; + }, + resolved.ankiConnect.behavior.mediaInsertMode, + "Expected 'append' or 'prepend'.", + ); + } + if (!hasOwn(behavior, 'highlightWord')) { + mapLegacy( + 'highlightWord', + asBoolean, + (value) => { + resolved.ankiConnect.behavior.highlightWord = value; + }, + resolved.ankiConnect.behavior.highlightWord, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'notificationType')) { + mapLegacy( + 'notificationType', + asNotificationType, + (value) => { + resolved.ankiConnect.behavior.notificationType = value; + }, + resolved.ankiConnect.behavior.notificationType, + "Expected 'osd', 'system', 'both', or 'none'.", + ); + } + if (!hasOwn(behavior, 'autoUpdateNewCards')) { + mapLegacy( + 'autoUpdateNewCards', + asBoolean, + (value) => { + resolved.ankiConnect.behavior.autoUpdateNewCards = value; + }, + resolved.ankiConnect.behavior.autoUpdateNewCards, + 'Expected boolean.', + ); + } + + const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record) : {}; + + const nPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled); + if (nPlusOneHighlightEnabled !== undefined) { + resolved.ankiConnect.nPlusOne.highlightEnabled = nPlusOneHighlightEnabled; + } else { + const legacyNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled); + if (legacyNPlusOneHighlightEnabled !== undefined) { + resolved.ankiConnect.nPlusOne.highlightEnabled = legacyNPlusOneHighlightEnabled; + warn( + 'ankiConnect.behavior.nPlusOneHighlightEnabled', + behavior.nPlusOneHighlightEnabled, + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, + 'Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled', + ); + } else if (nPlusOneConfig.highlightEnabled !== undefined) { + warn( + 'ankiConnect.nPlusOne.highlightEnabled', + nPlusOneConfig.highlightEnabled, + resolved.ankiConnect.nPlusOne.highlightEnabled, + 'Expected boolean.', + ); + resolved.ankiConnect.nPlusOne.highlightEnabled = + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; + } else { + resolved.ankiConnect.nPlusOne.highlightEnabled = + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; + } + } + + const nPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes); + const hasValidNPlusOneRefreshMinutes = + nPlusOneRefreshMinutes !== undefined && + Number.isInteger(nPlusOneRefreshMinutes) && + nPlusOneRefreshMinutes > 0; + if (nPlusOneRefreshMinutes !== undefined) { + if (hasValidNPlusOneRefreshMinutes) { + resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes; + } else { + warn( + 'ankiConnect.nPlusOne.refreshMinutes', + nPlusOneConfig.refreshMinutes, + resolved.ankiConnect.nPlusOne.refreshMinutes, + 'Expected a positive integer.', + ); + resolved.ankiConnect.nPlusOne.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; + } + } else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) { + const legacyNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes); + const hasValidLegacyRefreshMinutes = + legacyNPlusOneRefreshMinutes !== undefined && + Number.isInteger(legacyNPlusOneRefreshMinutes) && + legacyNPlusOneRefreshMinutes > 0; + if (hasValidLegacyRefreshMinutes) { + resolved.ankiConnect.nPlusOne.refreshMinutes = legacyNPlusOneRefreshMinutes; + warn( + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + behavior.nPlusOneRefreshMinutes, + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes, + 'Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes', + ); + } else { + warn( + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + behavior.nPlusOneRefreshMinutes, + resolved.ankiConnect.nPlusOne.refreshMinutes, + 'Expected a positive integer.', + ); + resolved.ankiConnect.nPlusOne.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; + } + } else { + resolved.ankiConnect.nPlusOne.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; + } + + const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords); + const hasValidNPlusOneMinSentenceWords = + nPlusOneMinSentenceWords !== undefined && + Number.isInteger(nPlusOneMinSentenceWords) && + nPlusOneMinSentenceWords > 0; + if (nPlusOneMinSentenceWords !== undefined) { + if (hasValidNPlusOneMinSentenceWords) { + resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords; + } else { + warn( + 'ankiConnect.nPlusOne.minSentenceWords', + nPlusOneConfig.minSentenceWords, + resolved.ankiConnect.nPlusOne.minSentenceWords, + 'Expected a positive integer.', + ); + resolved.ankiConnect.nPlusOne.minSentenceWords = + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; + } + } else { + resolved.ankiConnect.nPlusOne.minSentenceWords = + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; + } + + const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode); + const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode); + const hasValidNPlusOneMatchMode = + nPlusOneMatchMode === 'headword' || nPlusOneMatchMode === 'surface'; + const hasValidLegacyMatchMode = + legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface'; + if (hasValidNPlusOneMatchMode) { + resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode; + } else if (nPlusOneMatchMode !== undefined) { + warn( + 'ankiConnect.nPlusOne.matchMode', + nPlusOneConfig.matchMode, + DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, + "Expected 'headword' or 'surface'.", + ); + resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; + } else if (legacyNPlusOneMatchMode !== undefined) { + if (hasValidLegacyMatchMode) { + resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode; + warn( + 'ankiConnect.behavior.nPlusOneMatchMode', + behavior.nPlusOneMatchMode, + DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, + 'Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode', + ); + } else { + warn( + 'ankiConnect.behavior.nPlusOneMatchMode', + behavior.nPlusOneMatchMode, + resolved.ankiConnect.nPlusOne.matchMode, + "Expected 'headword' or 'surface'.", + ); + resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; + } + } else { + resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; + } + + const nPlusOneDecks = nPlusOneConfig.decks; + if (Array.isArray(nPlusOneDecks)) { + const normalizedDecks = nPlusOneDecks + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + if (normalizedDecks.length === nPlusOneDecks.length) { + resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)]; + } else if (nPlusOneDecks.length > 0) { + warn( + 'ankiConnect.nPlusOne.decks', + nPlusOneDecks, + resolved.ankiConnect.nPlusOne.decks, + 'Expected an array of strings.', + ); + } else { + resolved.ankiConnect.nPlusOne.decks = []; + } + } else if (nPlusOneDecks !== undefined) { + warn( + 'ankiConnect.nPlusOne.decks', + nPlusOneDecks, + resolved.ankiConnect.nPlusOne.decks, + 'Expected an array of strings.', + ); + resolved.ankiConnect.nPlusOne.decks = []; + } + + const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne); + if (nPlusOneHighlightColor !== undefined) { + resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor; + } else if (nPlusOneConfig.nPlusOne !== undefined) { + warn( + 'ankiConnect.nPlusOne.nPlusOne', + nPlusOneConfig.nPlusOne, + resolved.ankiConnect.nPlusOne.nPlusOne, + 'Expected a hex color value.', + ); + resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne; + } + + const nPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord); + if (nPlusOneKnownWordColor !== undefined) { + resolved.ankiConnect.nPlusOne.knownWord = nPlusOneKnownWordColor; + } else if (nPlusOneConfig.knownWord !== undefined) { + warn( + 'ankiConnect.nPlusOne.knownWord', + nPlusOneConfig.knownWord, + resolved.ankiConnect.nPlusOne.knownWord, + 'Expected a hex color value.', + ); + resolved.ankiConnect.nPlusOne.knownWord = DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord; + } + + if ( + resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' && + resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' && + resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled' + ) { + warn( + 'ankiConnect.isKiku.fieldGrouping', + resolved.ankiConnect.isKiku.fieldGrouping, + DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping, + 'Expected auto, manual, or disabled.', + ); + resolved.ankiConnect.isKiku.fieldGrouping = DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping; + } + } + + return { resolved, warnings }; +} diff --git a/src/config/service.ts b/src/config/service.ts index 34311f0..56d0c05 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -1,13 +1,9 @@ import * as fs from 'fs'; import * as path from 'path'; -import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; -import { Config, ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types'; +import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types'; import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions'; - -interface LoadResult { - config: RawConfig; - path: string; -} +import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load'; +import { resolveConfig } from './resolve'; export type ReloadConfigStrictResult = | { @@ -22,59 +18,20 @@ export type ReloadConfigStrictResult = path: string; }; -function isObject(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function asNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -function asBoolean(value: unknown): boolean | undefined { - return typeof value === 'boolean' ? value : undefined; -} - -const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; - -function asColor(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const text = value.trim(); - return hexColorPattern.test(text) ? text : undefined; -} - -function asFrequencyBandedColors( - value: unknown, -): [string, string, string, string, string] | undefined { - if (!Array.isArray(value) || value.length !== 5) { - return undefined; - } - - const colors = value.map((item) => asColor(item)); - if (colors.some((color) => color === undefined)) { - return undefined; - } - - return colors as [string, string, string, string, string]; -} - export class ConfigService { - private readonly configDir: string; - private readonly configFileJsonc: string; - private readonly configFileJson: string; + private readonly configPaths: ConfigPaths; private rawConfig: RawConfig = {}; private resolvedConfig: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG); private warnings: ConfigValidationWarning[] = []; private configPathInUse: string; constructor(configDir: string) { - this.configDir = configDir; - this.configFileJsonc = path.join(configDir, 'config.jsonc'); - this.configFileJson = path.join(configDir, 'config.json'); - this.configPathInUse = this.configFileJsonc; + this.configPaths = { + configDir, + configFileJsonc: path.join(configDir, 'config.jsonc'), + configFileJson: path.join(configDir, 'config.json'), + }; + this.configPathInUse = this.configPaths.configFileJsonc; this.reloadConfig(); } @@ -95,12 +52,12 @@ export class ConfigService { } reloadConfig(): ResolvedConfig { - const { config, path: configPath } = this.loadRawConfig(); + const { config, path: configPath } = loadRawConfig(this.configPaths); return this.applyResolvedConfig(config, configPath); } reloadConfigStrict(): ReloadConfigStrictResult { - const loadResult = this.loadRawConfigStrict(); + const loadResult = loadRawConfigStrict(this.configPaths); if (!loadResult.ok) { return loadResult; } @@ -116,12 +73,12 @@ export class ConfigService { } saveRawConfig(config: RawConfig): void { - if (!fs.existsSync(this.configDir)) { - fs.mkdirSync(this.configDir, { recursive: true }); + if (!fs.existsSync(this.configPaths.configDir)) { + fs.mkdirSync(this.configPaths.configDir, { recursive: true }); } const targetPath = this.configPathInUse.endsWith('.json') ? this.configPathInUse - : this.configFileJsonc; + : this.configPaths.configFileJsonc; fs.writeFileSync(targetPath, JSON.stringify(config, null, 2)); this.applyResolvedConfig(config, targetPath); } @@ -131,1459 +88,12 @@ export class ConfigService { this.saveRawConfig(merged); } - private loadRawConfig(): LoadResult { - const strictResult = this.loadRawConfigStrict(); - if (strictResult.ok) { - return strictResult; - } - return { config: {}, path: strictResult.path }; - } - - private loadRawConfigStrict(): - | (LoadResult & { ok: true }) - | { - ok: false; - error: string; - path: string; - } { - const configPath = this.resolveExistingConfigPath(); - - if (!fs.existsSync(configPath)) { - return { ok: true, config: {}, path: configPath }; - } - - try { - const data = fs.readFileSync(configPath, 'utf-8'); - const parsed = this.parseConfigContent(configPath, data); - return { - ok: true, - config: isObject(parsed) ? (parsed as Config) : {}, - path: configPath, - }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown parse error'; - return { ok: false, error: message, path: configPath }; - } - } - private applyResolvedConfig(config: RawConfig, configPath: string): ResolvedConfig { this.rawConfig = config; this.configPathInUse = configPath; - const { resolved, warnings } = this.resolveConfig(config); + const { resolved, warnings } = resolveConfig(config); this.resolvedConfig = resolved; this.warnings = warnings; return this.getConfig(); } - - private resolveExistingConfigPath(): string { - if (fs.existsSync(this.configFileJsonc)) { - return this.configFileJsonc; - } - if (fs.existsSync(this.configFileJson)) { - return this.configFileJson; - } - return this.configFileJsonc; - } - - private parseConfigContent(configPath: string, data: string): unknown { - if (!configPath.endsWith('.jsonc')) { - return JSON.parse(data); - } - - const errors: ParseError[] = []; - const result = parseJsonc(data, errors, { - allowTrailingComma: true, - disallowComments: false, - }); - if (errors.length > 0) { - throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`); - } - return result; - } - - private resolveConfig(raw: RawConfig): { - resolved: ResolvedConfig; - warnings: ConfigValidationWarning[]; - } { - const warnings: ConfigValidationWarning[] = []; - const resolved = deepCloneConfig(DEFAULT_CONFIG); - - const warn = (path: string, value: unknown, fallback: unknown, message: string): void => { - warnings.push({ - path, - value, - fallback, - message, - }); - }; - - const src = isObject(raw) ? raw : {}; - const knownTopLevelKeys = new Set(Object.keys(resolved)); - for (const key of Object.keys(src)) { - if (!knownTopLevelKeys.has(key)) { - warn(key, src[key], undefined, 'Unknown top-level config key; ignored.'); - } - } - - if (isObject(src.texthooker)) { - const openBrowser = asBoolean(src.texthooker.openBrowser); - if (openBrowser !== undefined) { - resolved.texthooker.openBrowser = openBrowser; - } else if (src.texthooker.openBrowser !== undefined) { - warn( - 'texthooker.openBrowser', - src.texthooker.openBrowser, - resolved.texthooker.openBrowser, - 'Expected boolean.', - ); - } - } - - if (isObject(src.websocket)) { - const enabled = src.websocket.enabled; - if (enabled === 'auto' || enabled === true || enabled === false) { - resolved.websocket.enabled = enabled; - } else if (enabled !== undefined) { - warn( - 'websocket.enabled', - enabled, - resolved.websocket.enabled, - "Expected true, false, or 'auto'.", - ); - } - - const port = asNumber(src.websocket.port); - if (port !== undefined && port > 0 && port <= 65535) { - resolved.websocket.port = Math.floor(port); - } else if (src.websocket.port !== undefined) { - warn( - 'websocket.port', - src.websocket.port, - resolved.websocket.port, - 'Expected integer between 1 and 65535.', - ); - } - } - - if (isObject(src.logging)) { - const logLevel = asString(src.logging.level); - if ( - logLevel === 'debug' || - logLevel === 'info' || - logLevel === 'warn' || - logLevel === 'error' - ) { - resolved.logging.level = logLevel; - } else if (src.logging.level !== undefined) { - warn( - 'logging.level', - src.logging.level, - resolved.logging.level, - 'Expected debug, info, warn, or error.', - ); - } - } - - if (Array.isArray(src.keybindings)) { - resolved.keybindings = src.keybindings.filter( - (entry): entry is { key: string; command: (string | number)[] | null } => { - if (!isObject(entry)) return false; - if (typeof entry.key !== 'string') return false; - if (entry.command === null) return true; - return Array.isArray(entry.command); - }, - ); - } - - if (isObject(src.shortcuts)) { - const shortcutKeys = [ - 'toggleVisibleOverlayGlobal', - 'toggleInvisibleOverlayGlobal', - 'copySubtitle', - 'copySubtitleMultiple', - 'updateLastCardFromClipboard', - 'triggerFieldGrouping', - 'triggerSubsync', - 'mineSentence', - 'mineSentenceMultiple', - 'toggleSecondarySub', - 'markAudioCard', - 'openRuntimeOptions', - 'openJimaku', - ] as const; - - for (const key of shortcutKeys) { - const value = src.shortcuts[key]; - if (typeof value === 'string' || value === null) { - resolved.shortcuts[key] = value as (typeof resolved.shortcuts)[typeof key]; - } else if (value !== undefined) { - warn(`shortcuts.${key}`, value, resolved.shortcuts[key], 'Expected string or null.'); - } - } - - const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs); - if (timeout !== undefined && timeout > 0) { - resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout); - } else if (src.shortcuts.multiCopyTimeoutMs !== undefined) { - warn( - 'shortcuts.multiCopyTimeoutMs', - src.shortcuts.multiCopyTimeoutMs, - resolved.shortcuts.multiCopyTimeoutMs, - 'Expected positive number.', - ); - } - } - - if (isObject(src.invisibleOverlay)) { - const startupVisibility = src.invisibleOverlay.startupVisibility; - if ( - startupVisibility === 'platform-default' || - startupVisibility === 'visible' || - startupVisibility === 'hidden' - ) { - resolved.invisibleOverlay.startupVisibility = startupVisibility; - } else if (startupVisibility !== undefined) { - warn( - 'invisibleOverlay.startupVisibility', - startupVisibility, - resolved.invisibleOverlay.startupVisibility, - 'Expected platform-default, visible, or hidden.', - ); - } - } - - if (isObject(src.secondarySub)) { - if (Array.isArray(src.secondarySub.secondarySubLanguages)) { - resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter( - (item): item is string => typeof item === 'string', - ); - } - const autoLoad = asBoolean(src.secondarySub.autoLoadSecondarySub); - if (autoLoad !== undefined) { - resolved.secondarySub.autoLoadSecondarySub = autoLoad; - } - const defaultMode = src.secondarySub.defaultMode; - if (defaultMode === 'hidden' || defaultMode === 'visible' || defaultMode === 'hover') { - resolved.secondarySub.defaultMode = defaultMode; - } else if (defaultMode !== undefined) { - warn( - 'secondarySub.defaultMode', - defaultMode, - resolved.secondarySub.defaultMode, - 'Expected hidden, visible, or hover.', - ); - } - } - - if (isObject(src.subsync)) { - const mode = src.subsync.defaultMode; - if (mode === 'auto' || mode === 'manual') { - resolved.subsync.defaultMode = mode; - } else if (mode !== undefined) { - warn('subsync.defaultMode', mode, resolved.subsync.defaultMode, 'Expected auto or manual.'); - } - - const alass = asString(src.subsync.alass_path); - if (alass !== undefined) resolved.subsync.alass_path = alass; - const ffsubsync = asString(src.subsync.ffsubsync_path); - if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync; - const ffmpeg = asString(src.subsync.ffmpeg_path); - if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg; - } - - if (isObject(src.subtitlePosition)) { - const y = asNumber(src.subtitlePosition.yPercent); - if (y !== undefined) { - resolved.subtitlePosition.yPercent = y; - } - } - - if (isObject(src.jimaku)) { - const apiKey = asString(src.jimaku.apiKey); - if (apiKey !== undefined) resolved.jimaku.apiKey = apiKey; - const apiKeyCommand = asString(src.jimaku.apiKeyCommand); - if (apiKeyCommand !== undefined) resolved.jimaku.apiKeyCommand = apiKeyCommand; - const apiBaseUrl = asString(src.jimaku.apiBaseUrl); - if (apiBaseUrl !== undefined) resolved.jimaku.apiBaseUrl = apiBaseUrl; - - const lang = src.jimaku.languagePreference; - if (lang === 'ja' || lang === 'en' || lang === 'none') { - resolved.jimaku.languagePreference = lang; - } else if (lang !== undefined) { - warn( - 'jimaku.languagePreference', - lang, - resolved.jimaku.languagePreference, - 'Expected ja, en, or none.', - ); - } - - const maxEntryResults = asNumber(src.jimaku.maxEntryResults); - if (maxEntryResults !== undefined && maxEntryResults > 0) { - resolved.jimaku.maxEntryResults = Math.floor(maxEntryResults); - } else if (src.jimaku.maxEntryResults !== undefined) { - warn( - 'jimaku.maxEntryResults', - src.jimaku.maxEntryResults, - resolved.jimaku.maxEntryResults, - 'Expected positive number.', - ); - } - } - - if (isObject(src.youtubeSubgen)) { - const mode = src.youtubeSubgen.mode; - if (mode === 'automatic' || mode === 'preprocess' || mode === 'off') { - resolved.youtubeSubgen.mode = mode; - } else if (mode !== undefined) { - warn( - 'youtubeSubgen.mode', - mode, - resolved.youtubeSubgen.mode, - 'Expected automatic, preprocess, or off.', - ); - } - - const whisperBin = asString(src.youtubeSubgen.whisperBin); - if (whisperBin !== undefined) { - resolved.youtubeSubgen.whisperBin = whisperBin; - } else if (src.youtubeSubgen.whisperBin !== undefined) { - warn( - 'youtubeSubgen.whisperBin', - src.youtubeSubgen.whisperBin, - resolved.youtubeSubgen.whisperBin, - 'Expected string.', - ); - } - - const whisperModel = asString(src.youtubeSubgen.whisperModel); - if (whisperModel !== undefined) { - resolved.youtubeSubgen.whisperModel = whisperModel; - } else if (src.youtubeSubgen.whisperModel !== undefined) { - warn( - 'youtubeSubgen.whisperModel', - src.youtubeSubgen.whisperModel, - resolved.youtubeSubgen.whisperModel, - 'Expected string.', - ); - } - - if (Array.isArray(src.youtubeSubgen.primarySubLanguages)) { - resolved.youtubeSubgen.primarySubLanguages = src.youtubeSubgen.primarySubLanguages.filter( - (item): item is string => typeof item === 'string', - ); - } else if (src.youtubeSubgen.primarySubLanguages !== undefined) { - warn( - 'youtubeSubgen.primarySubLanguages', - src.youtubeSubgen.primarySubLanguages, - resolved.youtubeSubgen.primarySubLanguages, - 'Expected string array.', - ); - } - } - - if (isObject(src.anilist)) { - const enabled = asBoolean(src.anilist.enabled); - if (enabled !== undefined) { - resolved.anilist.enabled = enabled; - } else if (src.anilist.enabled !== undefined) { - warn('anilist.enabled', src.anilist.enabled, resolved.anilist.enabled, 'Expected boolean.'); - } - - const accessToken = asString(src.anilist.accessToken); - if (accessToken !== undefined) { - resolved.anilist.accessToken = accessToken; - } else if (src.anilist.accessToken !== undefined) { - warn( - 'anilist.accessToken', - src.anilist.accessToken, - resolved.anilist.accessToken, - 'Expected string.', - ); - } - } - - if (isObject(src.jellyfin)) { - const enabled = asBoolean(src.jellyfin.enabled); - if (enabled !== undefined) { - resolved.jellyfin.enabled = enabled; - } else if (src.jellyfin.enabled !== undefined) { - warn( - 'jellyfin.enabled', - src.jellyfin.enabled, - resolved.jellyfin.enabled, - 'Expected boolean.', - ); - } - - const stringKeys = [ - 'serverUrl', - 'username', - 'deviceId', - 'clientName', - 'clientVersion', - 'defaultLibraryId', - 'iconCacheDir', - 'transcodeVideoCodec', - ] as const; - for (const key of stringKeys) { - const value = asString(src.jellyfin[key]); - if (value !== undefined) { - resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key]; - } else if (src.jellyfin[key] !== undefined) { - warn(`jellyfin.${key}`, src.jellyfin[key], resolved.jellyfin[key], 'Expected string.'); - } - } - - const booleanKeys = [ - 'remoteControlEnabled', - 'remoteControlAutoConnect', - 'autoAnnounce', - 'directPlayPreferred', - 'pullPictures', - ] as const; - for (const key of booleanKeys) { - const value = asBoolean(src.jellyfin[key]); - if (value !== undefined) { - resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key]; - } else if (src.jellyfin[key] !== undefined) { - warn(`jellyfin.${key}`, src.jellyfin[key], resolved.jellyfin[key], 'Expected boolean.'); - } - } - - if (Array.isArray(src.jellyfin.directPlayContainers)) { - resolved.jellyfin.directPlayContainers = src.jellyfin.directPlayContainers - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim().toLowerCase()) - .filter((item) => item.length > 0); - } else if (src.jellyfin.directPlayContainers !== undefined) { - warn( - 'jellyfin.directPlayContainers', - src.jellyfin.directPlayContainers, - resolved.jellyfin.directPlayContainers, - 'Expected string array.', - ); - } - } - - if (asBoolean(src.auto_start_overlay) !== undefined) { - resolved.auto_start_overlay = src.auto_start_overlay as boolean; - } - - if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) { - resolved.bind_visible_overlay_to_mpv_sub_visibility = - src.bind_visible_overlay_to_mpv_sub_visibility as boolean; - } else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) { - warn( - 'bind_visible_overlay_to_mpv_sub_visibility', - src.bind_visible_overlay_to_mpv_sub_visibility, - resolved.bind_visible_overlay_to_mpv_sub_visibility, - 'Expected boolean.', - ); - } - - if (isObject(src.immersionTracking)) { - const enabled = asBoolean(src.immersionTracking.enabled); - if (enabled !== undefined) { - resolved.immersionTracking.enabled = enabled; - } else if (src.immersionTracking.enabled !== undefined) { - warn( - 'immersionTracking.enabled', - src.immersionTracking.enabled, - resolved.immersionTracking.enabled, - 'Expected boolean.', - ); - } - - const dbPath = asString(src.immersionTracking.dbPath); - if (dbPath !== undefined) { - resolved.immersionTracking.dbPath = dbPath; - } else if (src.immersionTracking.dbPath !== undefined) { - warn( - 'immersionTracking.dbPath', - src.immersionTracking.dbPath, - resolved.immersionTracking.dbPath, - 'Expected string.', - ); - } - - const batchSize = asNumber(src.immersionTracking.batchSize); - if (batchSize !== undefined && batchSize >= 1 && batchSize <= 10_000) { - resolved.immersionTracking.batchSize = Math.floor(batchSize); - } else if (src.immersionTracking.batchSize !== undefined) { - warn( - 'immersionTracking.batchSize', - src.immersionTracking.batchSize, - resolved.immersionTracking.batchSize, - 'Expected integer between 1 and 10000.', - ); - } - - const flushIntervalMs = asNumber(src.immersionTracking.flushIntervalMs); - if (flushIntervalMs !== undefined && flushIntervalMs >= 50 && flushIntervalMs <= 60_000) { - resolved.immersionTracking.flushIntervalMs = Math.floor(flushIntervalMs); - } else if (src.immersionTracking.flushIntervalMs !== undefined) { - warn( - 'immersionTracking.flushIntervalMs', - src.immersionTracking.flushIntervalMs, - resolved.immersionTracking.flushIntervalMs, - 'Expected integer between 50 and 60000.', - ); - } - - const queueCap = asNumber(src.immersionTracking.queueCap); - if (queueCap !== undefined && queueCap >= 100 && queueCap <= 100_000) { - resolved.immersionTracking.queueCap = Math.floor(queueCap); - } else if (src.immersionTracking.queueCap !== undefined) { - warn( - 'immersionTracking.queueCap', - src.immersionTracking.queueCap, - resolved.immersionTracking.queueCap, - 'Expected integer between 100 and 100000.', - ); - } - - const payloadCapBytes = asNumber(src.immersionTracking.payloadCapBytes); - if (payloadCapBytes !== undefined && payloadCapBytes >= 64 && payloadCapBytes <= 8192) { - resolved.immersionTracking.payloadCapBytes = Math.floor(payloadCapBytes); - } else if (src.immersionTracking.payloadCapBytes !== undefined) { - warn( - 'immersionTracking.payloadCapBytes', - src.immersionTracking.payloadCapBytes, - resolved.immersionTracking.payloadCapBytes, - 'Expected integer between 64 and 8192.', - ); - } - - const maintenanceIntervalMs = asNumber(src.immersionTracking.maintenanceIntervalMs); - if ( - maintenanceIntervalMs !== undefined && - maintenanceIntervalMs >= 60_000 && - maintenanceIntervalMs <= 7 * 24 * 60 * 60 * 1000 - ) { - resolved.immersionTracking.maintenanceIntervalMs = Math.floor(maintenanceIntervalMs); - } else if (src.immersionTracking.maintenanceIntervalMs !== undefined) { - warn( - 'immersionTracking.maintenanceIntervalMs', - src.immersionTracking.maintenanceIntervalMs, - resolved.immersionTracking.maintenanceIntervalMs, - 'Expected integer between 60000 and 604800000.', - ); - } - - if (isObject(src.immersionTracking.retention)) { - const eventsDays = asNumber(src.immersionTracking.retention.eventsDays); - if (eventsDays !== undefined && eventsDays >= 1 && eventsDays <= 3650) { - resolved.immersionTracking.retention.eventsDays = Math.floor(eventsDays); - } else if (src.immersionTracking.retention.eventsDays !== undefined) { - warn( - 'immersionTracking.retention.eventsDays', - src.immersionTracking.retention.eventsDays, - resolved.immersionTracking.retention.eventsDays, - 'Expected integer between 1 and 3650.', - ); - } - - const telemetryDays = asNumber(src.immersionTracking.retention.telemetryDays); - if (telemetryDays !== undefined && telemetryDays >= 1 && telemetryDays <= 3650) { - resolved.immersionTracking.retention.telemetryDays = Math.floor(telemetryDays); - } else if (src.immersionTracking.retention.telemetryDays !== undefined) { - warn( - 'immersionTracking.retention.telemetryDays', - src.immersionTracking.retention.telemetryDays, - resolved.immersionTracking.retention.telemetryDays, - 'Expected integer between 1 and 3650.', - ); - } - - const dailyRollupsDays = asNumber(src.immersionTracking.retention.dailyRollupsDays); - if (dailyRollupsDays !== undefined && dailyRollupsDays >= 1 && dailyRollupsDays <= 36500) { - resolved.immersionTracking.retention.dailyRollupsDays = Math.floor(dailyRollupsDays); - } else if (src.immersionTracking.retention.dailyRollupsDays !== undefined) { - warn( - 'immersionTracking.retention.dailyRollupsDays', - src.immersionTracking.retention.dailyRollupsDays, - resolved.immersionTracking.retention.dailyRollupsDays, - 'Expected integer between 1 and 36500.', - ); - } - - const monthlyRollupsDays = asNumber(src.immersionTracking.retention.monthlyRollupsDays); - if ( - monthlyRollupsDays !== undefined && - monthlyRollupsDays >= 1 && - monthlyRollupsDays <= 36500 - ) { - resolved.immersionTracking.retention.monthlyRollupsDays = Math.floor(monthlyRollupsDays); - } else if (src.immersionTracking.retention.monthlyRollupsDays !== undefined) { - warn( - 'immersionTracking.retention.monthlyRollupsDays', - src.immersionTracking.retention.monthlyRollupsDays, - resolved.immersionTracking.retention.monthlyRollupsDays, - 'Expected integer between 1 and 36500.', - ); - } - - const vacuumIntervalDays = asNumber(src.immersionTracking.retention.vacuumIntervalDays); - if ( - vacuumIntervalDays !== undefined && - vacuumIntervalDays >= 1 && - vacuumIntervalDays <= 3650 - ) { - resolved.immersionTracking.retention.vacuumIntervalDays = Math.floor(vacuumIntervalDays); - } else if (src.immersionTracking.retention.vacuumIntervalDays !== undefined) { - warn( - 'immersionTracking.retention.vacuumIntervalDays', - src.immersionTracking.retention.vacuumIntervalDays, - resolved.immersionTracking.retention.vacuumIntervalDays, - 'Expected integer between 1 and 3650.', - ); - } - } else if (src.immersionTracking.retention !== undefined) { - warn( - 'immersionTracking.retention', - src.immersionTracking.retention, - resolved.immersionTracking.retention, - 'Expected object.', - ); - } - } - - if (isObject(src.subtitleStyle)) { - const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; - const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; - resolved.subtitleStyle = { - ...resolved.subtitleStyle, - ...(src.subtitleStyle as ResolvedConfig['subtitleStyle']), - secondary: { - ...resolved.subtitleStyle.secondary, - ...(isObject(src.subtitleStyle.secondary) - ? (src.subtitleStyle.secondary as ResolvedConfig['subtitleStyle']['secondary']) - : {}), - }, - }; - - const enableJlpt = asBoolean((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt); - if (enableJlpt !== undefined) { - resolved.subtitleStyle.enableJlpt = enableJlpt; - } else if ((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined) { - resolved.subtitleStyle.enableJlpt = fallbackSubtitleStyleEnableJlpt; - warn( - 'subtitleStyle.enableJlpt', - (src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt, - resolved.subtitleStyle.enableJlpt, - 'Expected boolean.', - ); - } - - const preserveLineBreaks = asBoolean( - (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks, - ); - if (preserveLineBreaks !== undefined) { - resolved.subtitleStyle.preserveLineBreaks = preserveLineBreaks; - } else if ( - (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks !== undefined - ) { - resolved.subtitleStyle.preserveLineBreaks = fallbackSubtitleStylePreserveLineBreaks; - warn( - 'subtitleStyle.preserveLineBreaks', - (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks, - resolved.subtitleStyle.preserveLineBreaks, - 'Expected boolean.', - ); - } - - const frequencyDictionary = isObject( - (src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary, - ) - ? ((src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary as Record< - string, - unknown - >) - : {}; - const frequencyEnabled = asBoolean((frequencyDictionary as { enabled?: unknown }).enabled); - if (frequencyEnabled !== undefined) { - resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled; - } else if ((frequencyDictionary as { enabled?: unknown }).enabled !== undefined) { - warn( - 'subtitleStyle.frequencyDictionary.enabled', - (frequencyDictionary as { enabled?: unknown }).enabled, - resolved.subtitleStyle.frequencyDictionary.enabled, - 'Expected boolean.', - ); - } - - const sourcePath = asString((frequencyDictionary as { sourcePath?: unknown }).sourcePath); - if (sourcePath !== undefined) { - resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath; - } else if ((frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined) { - warn( - 'subtitleStyle.frequencyDictionary.sourcePath', - (frequencyDictionary as { sourcePath?: unknown }).sourcePath, - resolved.subtitleStyle.frequencyDictionary.sourcePath, - 'Expected string.', - ); - } - - const topX = asNumber((frequencyDictionary as { topX?: unknown }).topX); - if (topX !== undefined && Number.isInteger(topX) && topX > 0) { - resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX); - } else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) { - warn( - 'subtitleStyle.frequencyDictionary.topX', - (frequencyDictionary as { topX?: unknown }).topX, - resolved.subtitleStyle.frequencyDictionary.topX, - 'Expected a positive integer.', - ); - } - - const frequencyMode = frequencyDictionary.mode; - if (frequencyMode === 'single' || frequencyMode === 'banded') { - resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode; - } else if (frequencyMode !== undefined) { - warn( - 'subtitleStyle.frequencyDictionary.mode', - frequencyDictionary.mode, - resolved.subtitleStyle.frequencyDictionary.mode, - "Expected 'single' or 'banded'.", - ); - } - - const singleColor = asColor((frequencyDictionary as { singleColor?: unknown }).singleColor); - if (singleColor !== undefined) { - resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor; - } else if ((frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined) { - warn( - 'subtitleStyle.frequencyDictionary.singleColor', - (frequencyDictionary as { singleColor?: unknown }).singleColor, - resolved.subtitleStyle.frequencyDictionary.singleColor, - 'Expected hex color.', - ); - } - - const bandedColors = asFrequencyBandedColors( - (frequencyDictionary as { bandedColors?: unknown }).bandedColors, - ); - if (bandedColors !== undefined) { - resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors; - } else if ((frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined) { - warn( - 'subtitleStyle.frequencyDictionary.bandedColors', - (frequencyDictionary as { bandedColors?: unknown }).bandedColors, - resolved.subtitleStyle.frequencyDictionary.bandedColors, - 'Expected an array of five hex colors.', - ); - } - } - - if (isObject(src.ankiConnect)) { - const ac = 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 aiSource = isObject(ac.ai) ? ac.ai : isObject(ac.openRouter) ? ac.openRouter : {}; - const legacyKeys = new Set([ - 'audioField', - 'imageField', - 'sentenceField', - 'miscInfoField', - 'miscInfoPattern', - 'generateAudio', - 'generateImage', - 'imageType', - 'imageFormat', - 'imageQuality', - 'imageMaxWidth', - 'imageMaxHeight', - 'animatedFps', - 'animatedMaxWidth', - 'animatedMaxHeight', - 'animatedCrf', - 'audioPadding', - 'fallbackDuration', - 'maxMediaDuration', - 'overwriteAudio', - 'overwriteImage', - 'mediaInsertMode', - 'highlightWord', - 'notificationType', - 'autoUpdateNewCards', - ]); - - if (ac.openRouter !== undefined) { - warn( - 'ankiConnect.openRouter', - ac.openRouter, - resolved.ankiConnect.ai, - 'Deprecated key; use ankiConnect.ai instead.', - ); - } - - const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } = - ac as Record; - const ankiConnectWithoutLegacy = Object.fromEntries( - Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)), - ); - - resolved.ankiConnect = { - ...resolved.ankiConnect, - ...(isObject(ankiConnectWithoutLegacy) - ? (ankiConnectWithoutLegacy as Partial) - : {}), - fields: { - ...resolved.ankiConnect.fields, - ...(isObject(ac.fields) ? (ac.fields as ResolvedConfig['ankiConnect']['fields']) : {}), - }, - ai: { - ...resolved.ankiConnect.ai, - ...(aiSource as ResolvedConfig['ankiConnect']['ai']), - }, - media: { - ...resolved.ankiConnect.media, - ...(isObject(ac.media) ? (ac.media as ResolvedConfig['ankiConnect']['media']) : {}), - }, - behavior: { - ...resolved.ankiConnect.behavior, - ...(isObject(ac.behavior) - ? (ac.behavior as ResolvedConfig['ankiConnect']['behavior']) - : {}), - }, - metadata: { - ...resolved.ankiConnect.metadata, - ...(isObject(ac.metadata) - ? (ac.metadata as ResolvedConfig['ankiConnect']['metadata']) - : {}), - }, - isLapis: { - ...resolved.ankiConnect.isLapis, - }, - isKiku: { - ...resolved.ankiConnect.isKiku, - ...(isObject(ac.isKiku) ? (ac.isKiku as ResolvedConfig['ankiConnect']['isKiku']) : {}), - }, - }; - - if (isObject(ac.isLapis)) { - const lapisEnabled = asBoolean(ac.isLapis.enabled); - if (lapisEnabled !== undefined) { - resolved.ankiConnect.isLapis.enabled = lapisEnabled; - } else if (ac.isLapis.enabled !== undefined) { - warn( - 'ankiConnect.isLapis.enabled', - ac.isLapis.enabled, - resolved.ankiConnect.isLapis.enabled, - 'Expected boolean.', - ); - } - - const sentenceCardModel = asString(ac.isLapis.sentenceCardModel); - if (sentenceCardModel !== undefined) { - resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel; - } else if (ac.isLapis.sentenceCardModel !== undefined) { - warn( - 'ankiConnect.isLapis.sentenceCardModel', - ac.isLapis.sentenceCardModel, - resolved.ankiConnect.isLapis.sentenceCardModel, - 'Expected string.', - ); - } - - if (ac.isLapis.sentenceCardSentenceField !== undefined) { - warn( - 'ankiConnect.isLapis.sentenceCardSentenceField', - ac.isLapis.sentenceCardSentenceField, - 'Sentence', - 'Deprecated key; sentence-card sentence field is fixed to Sentence.', - ); - } - - if (ac.isLapis.sentenceCardAudioField !== undefined) { - warn( - 'ankiConnect.isLapis.sentenceCardAudioField', - ac.isLapis.sentenceCardAudioField, - 'SentenceAudio', - 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.', - ); - } - } else if (ac.isLapis !== undefined) { - warn('ankiConnect.isLapis', ac.isLapis, resolved.ankiConnect.isLapis, 'Expected 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) { - resolved.ankiConnect.tags = [...new Set(normalizedTags)]; - } else { - resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; - warn( - 'ankiConnect.tags', - ac.tags, - resolved.ankiConnect.tags, - 'Expected an array of non-empty strings.', - ); - } - } else if (ac.tags !== undefined) { - resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; - warn( - 'ankiConnect.tags', - ac.tags, - resolved.ankiConnect.tags, - 'Expected an array of strings.', - ); - } - - const legacy = ac as Record; - const hasOwn = (obj: Record, key: string): boolean => - Object.prototype.hasOwnProperty.call(obj, key); - 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 asNotificationType = ( - value: unknown, - ): 'osd' | 'system' | 'both' | 'none' | undefined => { - return value === 'osd' || value === 'system' || value === 'both' || value === 'none' - ? 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) { - warn(`ankiConnect.${key}`, value, fallback, message); - return; - } - apply(parsed); - }; - - if (!hasOwn(fields, 'audio')) { - mapLegacy( - 'audioField', - asString, - (value) => { - resolved.ankiConnect.fields.audio = value; - }, - resolved.ankiConnect.fields.audio, - 'Expected string.', - ); - } - if (!hasOwn(fields, 'image')) { - mapLegacy( - 'imageField', - asString, - (value) => { - resolved.ankiConnect.fields.image = value; - }, - resolved.ankiConnect.fields.image, - 'Expected string.', - ); - } - if (!hasOwn(fields, 'sentence')) { - mapLegacy( - 'sentenceField', - asString, - (value) => { - resolved.ankiConnect.fields.sentence = value; - }, - resolved.ankiConnect.fields.sentence, - 'Expected string.', - ); - } - if (!hasOwn(fields, 'miscInfo')) { - mapLegacy( - 'miscInfoField', - asString, - (value) => { - resolved.ankiConnect.fields.miscInfo = value; - }, - resolved.ankiConnect.fields.miscInfo, - 'Expected string.', - ); - } - if (!hasOwn(metadata, 'pattern')) { - mapLegacy( - 'miscInfoPattern', - asString, - (value) => { - resolved.ankiConnect.metadata.pattern = value; - }, - resolved.ankiConnect.metadata.pattern, - 'Expected string.', - ); - } - if (!hasOwn(media, 'generateAudio')) { - mapLegacy( - 'generateAudio', - asBoolean, - (value) => { - resolved.ankiConnect.media.generateAudio = value; - }, - resolved.ankiConnect.media.generateAudio, - 'Expected boolean.', - ); - } - if (!hasOwn(media, 'generateImage')) { - mapLegacy( - 'generateImage', - asBoolean, - (value) => { - resolved.ankiConnect.media.generateImage = value; - }, - resolved.ankiConnect.media.generateImage, - 'Expected boolean.', - ); - } - if (!hasOwn(media, 'imageType')) { - mapLegacy( - 'imageType', - asImageType, - (value) => { - resolved.ankiConnect.media.imageType = value; - }, - resolved.ankiConnect.media.imageType, - "Expected 'static' or 'avif'.", - ); - } - if (!hasOwn(media, 'imageFormat')) { - mapLegacy( - 'imageFormat', - asImageFormat, - (value) => { - resolved.ankiConnect.media.imageFormat = value; - }, - resolved.ankiConnect.media.imageFormat, - "Expected 'jpg', 'png', or 'webp'.", - ); - } - if (!hasOwn(media, 'imageQuality')) { - mapLegacy( - 'imageQuality', - (value) => asIntegerInRange(value, 1, 100), - (value) => { - resolved.ankiConnect.media.imageQuality = value; - }, - resolved.ankiConnect.media.imageQuality, - 'Expected integer between 1 and 100.', - ); - } - if (!hasOwn(media, 'imageMaxWidth')) { - mapLegacy( - 'imageMaxWidth', - asPositiveInteger, - (value) => { - resolved.ankiConnect.media.imageMaxWidth = value; - }, - resolved.ankiConnect.media.imageMaxWidth, - 'Expected positive integer.', - ); - } - if (!hasOwn(media, 'imageMaxHeight')) { - mapLegacy( - 'imageMaxHeight', - asPositiveInteger, - (value) => { - resolved.ankiConnect.media.imageMaxHeight = value; - }, - resolved.ankiConnect.media.imageMaxHeight, - 'Expected positive integer.', - ); - } - if (!hasOwn(media, 'animatedFps')) { - mapLegacy( - 'animatedFps', - (value) => asIntegerInRange(value, 1, 60), - (value) => { - resolved.ankiConnect.media.animatedFps = value; - }, - resolved.ankiConnect.media.animatedFps, - 'Expected integer between 1 and 60.', - ); - } - if (!hasOwn(media, 'animatedMaxWidth')) { - mapLegacy( - 'animatedMaxWidth', - asPositiveInteger, - (value) => { - resolved.ankiConnect.media.animatedMaxWidth = value; - }, - resolved.ankiConnect.media.animatedMaxWidth, - 'Expected positive integer.', - ); - } - if (!hasOwn(media, 'animatedMaxHeight')) { - mapLegacy( - 'animatedMaxHeight', - asPositiveInteger, - (value) => { - resolved.ankiConnect.media.animatedMaxHeight = value; - }, - resolved.ankiConnect.media.animatedMaxHeight, - 'Expected positive integer.', - ); - } - if (!hasOwn(media, 'animatedCrf')) { - mapLegacy( - 'animatedCrf', - (value) => asIntegerInRange(value, 0, 63), - (value) => { - resolved.ankiConnect.media.animatedCrf = value; - }, - resolved.ankiConnect.media.animatedCrf, - 'Expected integer between 0 and 63.', - ); - } - if (!hasOwn(media, 'audioPadding')) { - mapLegacy( - 'audioPadding', - asNonNegativeNumber, - (value) => { - resolved.ankiConnect.media.audioPadding = value; - }, - resolved.ankiConnect.media.audioPadding, - 'Expected non-negative number.', - ); - } - if (!hasOwn(media, 'fallbackDuration')) { - mapLegacy( - 'fallbackDuration', - asPositiveNumber, - (value) => { - resolved.ankiConnect.media.fallbackDuration = value; - }, - resolved.ankiConnect.media.fallbackDuration, - 'Expected positive number.', - ); - } - if (!hasOwn(media, 'maxMediaDuration')) { - mapLegacy( - 'maxMediaDuration', - asNonNegativeNumber, - (value) => { - resolved.ankiConnect.media.maxMediaDuration = value; - }, - resolved.ankiConnect.media.maxMediaDuration, - 'Expected non-negative number.', - ); - } - if (!hasOwn(behavior, 'overwriteAudio')) { - mapLegacy( - 'overwriteAudio', - asBoolean, - (value) => { - resolved.ankiConnect.behavior.overwriteAudio = value; - }, - resolved.ankiConnect.behavior.overwriteAudio, - 'Expected boolean.', - ); - } - if (!hasOwn(behavior, 'overwriteImage')) { - mapLegacy( - 'overwriteImage', - asBoolean, - (value) => { - resolved.ankiConnect.behavior.overwriteImage = value; - }, - resolved.ankiConnect.behavior.overwriteImage, - 'Expected boolean.', - ); - } - if (!hasOwn(behavior, 'mediaInsertMode')) { - mapLegacy( - 'mediaInsertMode', - asMediaInsertMode, - (value) => { - resolved.ankiConnect.behavior.mediaInsertMode = value; - }, - resolved.ankiConnect.behavior.mediaInsertMode, - "Expected 'append' or 'prepend'.", - ); - } - if (!hasOwn(behavior, 'highlightWord')) { - mapLegacy( - 'highlightWord', - asBoolean, - (value) => { - resolved.ankiConnect.behavior.highlightWord = value; - }, - resolved.ankiConnect.behavior.highlightWord, - 'Expected boolean.', - ); - } - if (!hasOwn(behavior, 'notificationType')) { - mapLegacy( - 'notificationType', - asNotificationType, - (value) => { - resolved.ankiConnect.behavior.notificationType = value; - }, - resolved.ankiConnect.behavior.notificationType, - "Expected 'osd', 'system', 'both', or 'none'.", - ); - } - if (!hasOwn(behavior, 'autoUpdateNewCards')) { - mapLegacy( - 'autoUpdateNewCards', - asBoolean, - (value) => { - resolved.ankiConnect.behavior.autoUpdateNewCards = value; - }, - resolved.ankiConnect.behavior.autoUpdateNewCards, - 'Expected boolean.', - ); - } - - const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record) : {}; - - const nPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled); - if (nPlusOneHighlightEnabled !== undefined) { - resolved.ankiConnect.nPlusOne.highlightEnabled = nPlusOneHighlightEnabled; - } else { - const legacyNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled); - if (legacyNPlusOneHighlightEnabled !== undefined) { - resolved.ankiConnect.nPlusOne.highlightEnabled = legacyNPlusOneHighlightEnabled; - warn( - 'ankiConnect.behavior.nPlusOneHighlightEnabled', - behavior.nPlusOneHighlightEnabled, - DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, - 'Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled', - ); - } else if (nPlusOneConfig.highlightEnabled !== undefined) { - warn( - 'ankiConnect.nPlusOne.highlightEnabled', - nPlusOneConfig.highlightEnabled, - resolved.ankiConnect.nPlusOne.highlightEnabled, - 'Expected boolean.', - ); - resolved.ankiConnect.nPlusOne.highlightEnabled = - DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; - } else { - resolved.ankiConnect.nPlusOne.highlightEnabled = - DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; - } - } - - const nPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes); - const hasValidNPlusOneRefreshMinutes = - nPlusOneRefreshMinutes !== undefined && - Number.isInteger(nPlusOneRefreshMinutes) && - nPlusOneRefreshMinutes > 0; - if (nPlusOneRefreshMinutes !== undefined) { - if (hasValidNPlusOneRefreshMinutes) { - resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes; - } else { - warn( - 'ankiConnect.nPlusOne.refreshMinutes', - nPlusOneConfig.refreshMinutes, - resolved.ankiConnect.nPlusOne.refreshMinutes, - 'Expected a positive integer.', - ); - resolved.ankiConnect.nPlusOne.refreshMinutes = - DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; - } - } else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) { - const legacyNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes); - const hasValidLegacyRefreshMinutes = - legacyNPlusOneRefreshMinutes !== undefined && - Number.isInteger(legacyNPlusOneRefreshMinutes) && - legacyNPlusOneRefreshMinutes > 0; - if (hasValidLegacyRefreshMinutes) { - resolved.ankiConnect.nPlusOne.refreshMinutes = legacyNPlusOneRefreshMinutes; - warn( - 'ankiConnect.behavior.nPlusOneRefreshMinutes', - behavior.nPlusOneRefreshMinutes, - DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes, - 'Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes', - ); - } else { - warn( - 'ankiConnect.behavior.nPlusOneRefreshMinutes', - behavior.nPlusOneRefreshMinutes, - resolved.ankiConnect.nPlusOne.refreshMinutes, - 'Expected a positive integer.', - ); - resolved.ankiConnect.nPlusOne.refreshMinutes = - DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; - } - } else { - resolved.ankiConnect.nPlusOne.refreshMinutes = - DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; - } - - const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords); - const hasValidNPlusOneMinSentenceWords = - nPlusOneMinSentenceWords !== undefined && - Number.isInteger(nPlusOneMinSentenceWords) && - nPlusOneMinSentenceWords > 0; - if (nPlusOneMinSentenceWords !== undefined) { - if (hasValidNPlusOneMinSentenceWords) { - resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords; - } else { - warn( - 'ankiConnect.nPlusOne.minSentenceWords', - nPlusOneConfig.minSentenceWords, - resolved.ankiConnect.nPlusOne.minSentenceWords, - 'Expected a positive integer.', - ); - resolved.ankiConnect.nPlusOne.minSentenceWords = - DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; - } - } else { - resolved.ankiConnect.nPlusOne.minSentenceWords = - DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; - } - - const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode); - const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode); - const hasValidNPlusOneMatchMode = - nPlusOneMatchMode === 'headword' || nPlusOneMatchMode === 'surface'; - const hasValidLegacyMatchMode = - legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface'; - if (hasValidNPlusOneMatchMode) { - resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode; - } else if (nPlusOneMatchMode !== undefined) { - warn( - 'ankiConnect.nPlusOne.matchMode', - nPlusOneConfig.matchMode, - DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, - "Expected 'headword' or 'surface'.", - ); - resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; - } else if (legacyNPlusOneMatchMode !== undefined) { - if (hasValidLegacyMatchMode) { - resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode; - warn( - 'ankiConnect.behavior.nPlusOneMatchMode', - behavior.nPlusOneMatchMode, - DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, - 'Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode', - ); - } else { - warn( - 'ankiConnect.behavior.nPlusOneMatchMode', - behavior.nPlusOneMatchMode, - resolved.ankiConnect.nPlusOne.matchMode, - "Expected 'headword' or 'surface'.", - ); - resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; - } - } else { - resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; - } - - const nPlusOneDecks = nPlusOneConfig.decks; - if (Array.isArray(nPlusOneDecks)) { - const normalizedDecks = nPlusOneDecks - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - - if (normalizedDecks.length === nPlusOneDecks.length) { - resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)]; - } else if (nPlusOneDecks.length > 0) { - warn( - 'ankiConnect.nPlusOne.decks', - nPlusOneDecks, - resolved.ankiConnect.nPlusOne.decks, - 'Expected an array of strings.', - ); - } else { - resolved.ankiConnect.nPlusOne.decks = []; - } - } else if (nPlusOneDecks !== undefined) { - warn( - 'ankiConnect.nPlusOne.decks', - nPlusOneDecks, - resolved.ankiConnect.nPlusOne.decks, - 'Expected an array of strings.', - ); - resolved.ankiConnect.nPlusOne.decks = []; - } - - const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne); - if (nPlusOneHighlightColor !== undefined) { - resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor; - } else if (nPlusOneConfig.nPlusOne !== undefined) { - warn( - 'ankiConnect.nPlusOne.nPlusOne', - nPlusOneConfig.nPlusOne, - resolved.ankiConnect.nPlusOne.nPlusOne, - 'Expected a hex color value.', - ); - resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne; - } - - const nPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord); - if (nPlusOneKnownWordColor !== undefined) { - resolved.ankiConnect.nPlusOne.knownWord = nPlusOneKnownWordColor; - } else if (nPlusOneConfig.knownWord !== undefined) { - warn( - 'ankiConnect.nPlusOne.knownWord', - nPlusOneConfig.knownWord, - resolved.ankiConnect.nPlusOne.knownWord, - 'Expected a hex color value.', - ); - resolved.ankiConnect.nPlusOne.knownWord = DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord; - } - - if ( - resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' && - resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' && - resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled' - ) { - warn( - 'ankiConnect.isKiku.fieldGrouping', - resolved.ankiConnect.isKiku.fieldGrouping, - DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping, - 'Expected auto, manual, or disabled.', - ); - resolved.ankiConnect.isKiku.fieldGrouping = DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping; - } - } - - return { resolved, warnings }; - } } diff --git a/src/config/warnings.ts b/src/config/warnings.ts new file mode 100644 index 0000000..ffa95ff --- /dev/null +++ b/src/config/warnings.ts @@ -0,0 +1,19 @@ +import { ConfigValidationWarning } from '../types'; + +export interface WarningCollector { + warnings: ConfigValidationWarning[]; + warn(path: string, value: unknown, fallback: unknown, message: string): void; +} + +export function createWarningCollector(): WarningCollector { + const warnings: ConfigValidationWarning[] = []; + const warn = (path: string, value: unknown, fallback: unknown, message: string): void => { + warnings.push({ + path, + value, + fallback, + message, + }); + }; + return { warnings, warn }; +} diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 13eba46..9e98128 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -4,6 +4,14 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { DatabaseSync as NodeDatabaseSync } from 'node:sqlite'; +import { toMonthKey } from './immersion-tracker/maintenance'; +import { enqueueWrite } from './immersion-tracker/queue'; +import { + deriveCanonicalTitle, + normalizeText, + resolveBoundedInt, +} from './immersion-tracker/reducer'; +import type { QueuedWrite } from './immersion-tracker/types'; type ImmersionTrackerService = import('./immersion-tracker-service').ImmersionTrackerService; type ImmersionTrackerServiceCtor = @@ -40,6 +48,41 @@ function cleanupDbPath(dbPath: string): void { } } +test('seam: resolveBoundedInt keeps fallback for invalid values', () => { + assert.equal(resolveBoundedInt(undefined, 25, 1, 100), 25); + assert.equal(resolveBoundedInt(0, 25, 1, 100), 25); + assert.equal(resolveBoundedInt(101, 25, 1, 100), 25); + assert.equal(resolveBoundedInt(44.8, 25, 1, 100), 44); +}); + +test('seam: reducer title normalization covers local and remote paths', () => { + assert.equal(normalizeText(' hello\n world '), 'hello world'); + assert.equal(deriveCanonicalTitle('/tmp/Episode 01.mkv'), 'Episode 01'); + assert.equal( + deriveCanonicalTitle('https://cdn.example.com/show/%E7%AC%AC1%E8%A9%B1.mp4'), + '\u7b2c1\u8a71', + ); +}); + +test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () => { + const queue: QueuedWrite[] = [ + { kind: 'event', sessionId: 1, eventType: 1, sampleMs: 1000 }, + { kind: 'event', sessionId: 1, eventType: 2, sampleMs: 1001 }, + ]; + const incoming: QueuedWrite = { kind: 'event', sessionId: 1, eventType: 3, sampleMs: 1002 }; + + const result = enqueueWrite(queue, incoming, 2); + assert.equal(result.dropped, 1); + assert.equal(queue.length, 2); + assert.equal(queue[0]!.eventType, 2); + assert.equal(queue[1]!.eventType, 3); +}); + +test('seam: toMonthKey uses UTC calendar month', () => { + assert.equal(toMonthKey(Date.UTC(2026, 0, 31, 23, 59, 59, 999)), 202601); + assert.equal(toMonthKey(Date.UTC(2026, 1, 1, 0, 0, 0, 0)), 202602); +}); + testIfSqlite('startSession generates UUID-like session identifiers', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 56b6e38..3342a49 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -4,163 +4,71 @@ import { spawn } from 'node:child_process'; import { DatabaseSync } from 'node:sqlite'; import * as fs from 'node:fs'; import { createLogger } from '../../logger'; +import { pruneRetention, runRollupMaintenance } from './immersion-tracker/maintenance'; +import { + getDailyRollups, + getMonthlyRollups, + getQueryHints, + getSessionSummaries, + getSessionTimeline, +} from './immersion-tracker/query'; +import { + buildVideoKey, + calculateTextMetrics, + createInitialSessionState, + deriveCanonicalTitle, + emptyMetadata, + hashToCode, + isRemoteSource, + normalizeMediaPath, + normalizeText, + parseFps, + resolveBoundedInt, + sanitizePayload, + secToMs, + toNullableInt, +} from './immersion-tracker/reducer'; +import { enqueueWrite } from './immersion-tracker/queue'; +import { + DEFAULT_BATCH_SIZE, + DEFAULT_DAILY_ROLLUP_RETENTION_MS, + DEFAULT_EVENTS_RETENTION_MS, + DEFAULT_FLUSH_INTERVAL_MS, + DEFAULT_MAINTENANCE_INTERVAL_MS, + DEFAULT_MAX_PAYLOAD_BYTES, + DEFAULT_MONTHLY_ROLLUP_RETENTION_MS, + DEFAULT_QUEUE_CAP, + DEFAULT_TELEMETRY_RETENTION_MS, + DEFAULT_VACUUM_INTERVAL_MS, + EVENT_CARD_MINED, + EVENT_LOOKUP, + EVENT_MEDIA_BUFFER, + EVENT_PAUSE_END, + EVENT_PAUSE_START, + EVENT_SEEK_BACKWARD, + EVENT_SEEK_FORWARD, + EVENT_SUBTITLE_LINE, + SCHEMA_VERSION, + SESSION_STATUS_ACTIVE, + SESSION_STATUS_ENDED, + SOURCE_TYPE_LOCAL, + SOURCE_TYPE_REMOTE, + type ImmersionSessionRollupRow, + type ImmersionTrackerOptions, + type QueuedWrite, + type SessionState, + type SessionSummaryQueryRow, + type SessionTimelineRow, + type VideoMetadata, +} from './immersion-tracker/types'; -const SCHEMA_VERSION = 1; -const DEFAULT_QUEUE_CAP = 1_000; -const DEFAULT_BATCH_SIZE = 25; -const DEFAULT_FLUSH_INTERVAL_MS = 500; -const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000; -const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; -const DEFAULT_EVENTS_RETENTION_MS = ONE_WEEK_MS; -const DEFAULT_VACUUM_INTERVAL_MS = ONE_WEEK_MS; -const DEFAULT_TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; -const DEFAULT_DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000; -const DEFAULT_MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000; -const DEFAULT_MAX_PAYLOAD_BYTES = 256; - -const SOURCE_TYPE_LOCAL = 1; -const SOURCE_TYPE_REMOTE = 2; - -const SESSION_STATUS_ACTIVE = 1; -const SESSION_STATUS_ENDED = 2; - -const EVENT_SUBTITLE_LINE = 1; -const EVENT_MEDIA_BUFFER = 2; -const EVENT_LOOKUP = 3; -const EVENT_CARD_MINED = 4; -const EVENT_SEEK_FORWARD = 5; -const EVENT_SEEK_BACKWARD = 6; -const EVENT_PAUSE_START = 7; -const EVENT_PAUSE_END = 8; - -export interface ImmersionTrackerOptions { - dbPath: string; - policy?: ImmersionTrackerPolicy; -} - -export interface ImmersionTrackerPolicy { - queueCap?: number; - batchSize?: number; - flushIntervalMs?: number; - maintenanceIntervalMs?: number; - payloadCapBytes?: number; - retention?: { - eventsDays?: number; - telemetryDays?: number; - dailyRollupsDays?: number; - monthlyRollupsDays?: number; - vacuumIntervalDays?: number; - }; -} - -interface TelemetryAccumulator { - totalWatchedMs: number; - activeWatchedMs: number; - linesSeen: number; - wordsSeen: number; - tokensSeen: number; - cardsMined: number; - lookupCount: number; - lookupHits: number; - pauseCount: number; - pauseMs: number; - seekForwardCount: number; - seekBackwardCount: number; - mediaBufferEvents: number; -} - -interface SessionState extends TelemetryAccumulator { - sessionId: number; - videoId: number; - startedAtMs: number; - currentLineIndex: number; - lastWallClockMs: number; - lastMediaMs: number | null; - lastPauseStartMs: number | null; - isPaused: boolean; - pendingTelemetry: boolean; -} - -interface QueuedWrite { - kind: 'telemetry' | 'event'; - sessionId: number; - sampleMs?: number; - totalWatchedMs?: number; - activeWatchedMs?: number; - linesSeen?: number; - wordsSeen?: number; - tokensSeen?: number; - cardsMined?: number; - lookupCount?: number; - lookupHits?: number; - pauseCount?: number; - pauseMs?: number; - seekForwardCount?: number; - seekBackwardCount?: number; - mediaBufferEvents?: number; - eventType?: number; - lineIndex?: number | null; - segmentStartMs?: number | null; - segmentEndMs?: number | null; - wordsDelta?: number; - cardsDelta?: number; - payloadJson?: string | null; -} - -interface VideoMetadata { - sourceType: number; - canonicalTitle: string; - durationMs: number; - fileSizeBytes: number | null; - codecId: number | null; - containerId: number | null; - widthPx: number | null; - heightPx: number | null; - fpsX100: number | null; - bitrateKbps: number | null; - audioCodecId: number | null; - hashSha256: string | null; - screenshotPath: string | null; - metadataJson: string | null; -} - -export interface SessionSummaryQueryRow { - videoId: number | null; - startedAtMs: number; - endedAtMs: number | null; - totalWatchedMs: number; - activeWatchedMs: number; - linesSeen: number; - wordsSeen: number; - tokensSeen: number; - cardsMined: number; - lookupCount: number; - lookupHits: number; -} - -export interface SessionTimelineRow { - sampleMs: number; - totalWatchedMs: number; - activeWatchedMs: number; - linesSeen: number; - wordsSeen: number; - tokensSeen: number; - cardsMined: number; -} - -export interface ImmersionSessionRollupRow { - rollupDayOrMonth: number; - videoId: number | null; - totalSessions: number; - totalActiveMin: number; - totalLinesSeen: number; - totalWordsSeen: number; - totalTokensSeen: number; - totalCards: number; - cardsPerHour: number | null; - wordsPerMin: number | null; - lookupHitRate: number | null; -} +export type { + ImmersionSessionRollupRow, + ImmersionTrackerOptions, + ImmersionTrackerPolicy, + SessionSummaryQueryRow, + SessionTimelineRow, +} from './immersion-tracker/types'; export class ImmersionTrackerService { private readonly logger = createLogger('main:immersion-tracker'); @@ -200,21 +108,21 @@ export class ImmersionTrackerService { } const policy = options.policy ?? {}; - this.queueCap = this.resolveBoundedInt(policy.queueCap, DEFAULT_QUEUE_CAP, 100, 100_000); - this.batchSize = this.resolveBoundedInt(policy.batchSize, DEFAULT_BATCH_SIZE, 1, 10_000); - this.flushIntervalMs = this.resolveBoundedInt( + this.queueCap = resolveBoundedInt(policy.queueCap, DEFAULT_QUEUE_CAP, 100, 100_000); + this.batchSize = resolveBoundedInt(policy.batchSize, DEFAULT_BATCH_SIZE, 1, 10_000); + this.flushIntervalMs = resolveBoundedInt( policy.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS, 50, 60_000, ); - this.maintenanceIntervalMs = this.resolveBoundedInt( + this.maintenanceIntervalMs = resolveBoundedInt( policy.maintenanceIntervalMs, DEFAULT_MAINTENANCE_INTERVAL_MS, 60_000, 7 * 24 * 60 * 60 * 1000, ); - this.maxPayloadBytes = this.resolveBoundedInt( + this.maxPayloadBytes = resolveBoundedInt( policy.payloadCapBytes, DEFAULT_MAX_PAYLOAD_BYTES, 64, @@ -223,35 +131,35 @@ export class ImmersionTrackerService { const retention = policy.retention ?? {}; this.eventsRetentionMs = - this.resolveBoundedInt( + resolveBoundedInt( retention.eventsDays, Math.floor(DEFAULT_EVENTS_RETENTION_MS / 86_400_000), 1, 3650, ) * 86_400_000; this.telemetryRetentionMs = - this.resolveBoundedInt( + resolveBoundedInt( retention.telemetryDays, Math.floor(DEFAULT_TELEMETRY_RETENTION_MS / 86_400_000), 1, 3650, ) * 86_400_000; this.dailyRollupRetentionMs = - this.resolveBoundedInt( + resolveBoundedInt( retention.dailyRollupsDays, Math.floor(DEFAULT_DAILY_ROLLUP_RETENTION_MS / 86_400_000), 1, 36500, ) * 86_400_000; this.monthlyRollupRetentionMs = - this.resolveBoundedInt( + resolveBoundedInt( retention.monthlyRollupsDays, Math.floor(DEFAULT_MONTHLY_ROLLUP_RETENTION_MS / 86_400_000), 1, 36500, ) * 86_400_000; this.vacuumIntervalMs = - this.resolveBoundedInt( + resolveBoundedInt( retention.vacuumIntervalDays, Math.floor(DEFAULT_VACUUM_INTERVAL_MS / 86_400_000), 1, @@ -300,104 +208,31 @@ export class ImmersionTrackerService { } async getSessionSummaries(limit = 50): Promise { - const prepared = this.db.prepare(` - SELECT - s.video_id AS videoId, - s.started_at_ms AS startedAtMs, - s.ended_at_ms AS endedAtMs, - COALESCE(SUM(t.total_watched_ms), 0) AS totalWatchedMs, - COALESCE(SUM(t.active_watched_ms), 0) AS activeWatchedMs, - COALESCE(SUM(t.lines_seen), 0) AS linesSeen, - COALESCE(SUM(t.words_seen), 0) AS wordsSeen, - COALESCE(SUM(t.tokens_seen), 0) AS tokensSeen, - COALESCE(SUM(t.cards_mined), 0) AS cardsMined, - COALESCE(SUM(t.lookup_count), 0) AS lookupCount, - COALESCE(SUM(t.lookup_hits), 0) AS lookupHits - FROM imm_sessions s - LEFT JOIN imm_session_telemetry t ON t.session_id = s.session_id - GROUP BY s.session_id - ORDER BY s.started_at_ms DESC - LIMIT ? - `); - return prepared.all(limit) as unknown as SessionSummaryQueryRow[]; + return getSessionSummaries(this.db, limit); } async getSessionTimeline(sessionId: number, limit = 200): Promise { - const prepared = this.db.prepare(` - SELECT - sample_ms AS sampleMs, - total_watched_ms AS totalWatchedMs, - active_watched_ms AS activeWatchedMs, - lines_seen AS linesSeen, - words_seen AS wordsSeen, - tokens_seen AS tokensSeen, - cards_mined AS cardsMined - FROM imm_session_telemetry - WHERE session_id = ? - ORDER BY sample_ms DESC - LIMIT ? - `); - return prepared.all(sessionId, limit) as unknown as SessionTimelineRow[]; + return getSessionTimeline(this.db, sessionId, limit); } async getQueryHints(): Promise<{ totalSessions: number; activeSessions: number; }> { - const sessions = this.db.prepare('SELECT COUNT(*) AS total FROM imm_sessions'); - const active = this.db.prepare( - 'SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL', - ); - const totalSessions = Number(sessions.get()?.total ?? 0); - const activeSessions = Number(active.get()?.total ?? 0); - return { totalSessions, activeSessions }; + return getQueryHints(this.db); } async getDailyRollups(limit = 60): Promise { - const prepared = this.db.prepare(` - SELECT - rollup_day AS rollupDayOrMonth, - video_id AS videoId, - total_sessions AS totalSessions, - total_active_min AS totalActiveMin, - total_lines_seen AS totalLinesSeen, - total_words_seen AS totalWordsSeen, - total_tokens_seen AS totalTokensSeen, - total_cards AS totalCards, - cards_per_hour AS cardsPerHour, - words_per_min AS wordsPerMin, - lookup_hit_rate AS lookupHitRate - FROM imm_daily_rollups - ORDER BY rollup_day DESC, video_id DESC - LIMIT ? - `); - return prepared.all(limit) as unknown as ImmersionSessionRollupRow[]; + return getDailyRollups(this.db, limit); } async getMonthlyRollups(limit = 24): Promise { - const prepared = this.db.prepare(` - SELECT - rollup_month AS rollupDayOrMonth, - video_id AS videoId, - total_sessions AS totalSessions, - total_active_min AS totalActiveMin, - total_lines_seen AS totalLinesSeen, - total_words_seen AS totalWordsSeen, - total_tokens_seen AS totalTokensSeen, - total_cards AS totalCards, - 0 AS cardsPerHour, - 0 AS wordsPerMin, - 0 AS lookupHitRate - FROM imm_monthly_rollups - ORDER BY rollup_month DESC, video_id DESC - LIMIT ? - `); - return prepared.all(limit) as unknown as ImmersionSessionRollupRow[]; + return getMonthlyRollups(this.db, limit); } handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { - const normalizedPath = this.normalizeMediaPath(mediaPath); - const normalizedTitle = this.normalizeText(mediaTitle); + const normalizedPath = normalizeMediaPath(mediaPath); + const normalizedTitle = normalizeText(mediaTitle); this.logger.info( `handleMediaChange called with path=${normalizedPath || ''} title=${normalizedTitle || ''}`, ); @@ -419,9 +254,9 @@ export class ImmersionTrackerService { return; } - const sourceType = this.isRemoteSource(normalizedPath) ? SOURCE_TYPE_REMOTE : SOURCE_TYPE_LOCAL; - const videoKey = this.buildVideoKey(normalizedPath, sourceType); - const canonicalTitle = normalizedTitle || this.deriveCanonicalTitle(normalizedPath); + const sourceType = isRemoteSource(normalizedPath) ? SOURCE_TYPE_REMOTE : SOURCE_TYPE_LOCAL; + const videoKey = buildVideoKey(normalizedPath, sourceType); + const canonicalTitle = normalizedTitle || deriveCanonicalTitle(normalizedPath); const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? normalizedPath : null; const sourceUrl = sourceType === SOURCE_TYPE_REMOTE ? normalizedPath : null; @@ -444,7 +279,7 @@ export class ImmersionTrackerService { handleMediaTitleUpdate(mediaTitle: string | null): void { if (!this.sessionState) return; - const normalizedTitle = this.normalizeText(mediaTitle); + const normalizedTitle = normalizeText(mediaTitle); if (!normalizedTitle) return; this.currentVideoKey = normalizedTitle; this.updateVideoTitleForActiveSession(normalizedTitle); @@ -452,10 +287,10 @@ export class ImmersionTrackerService { recordSubtitleLine(text: string, startSec: number, endSec: number): void { if (!this.sessionState || !text.trim()) return; - const cleaned = this.normalizeText(text); + const cleaned = normalizeText(text); if (!cleaned) return; - const metrics = this.calculateTextMetrics(cleaned); + const metrics = calculateTextMetrics(cleaned); this.sessionState.currentLineIndex += 1; this.sessionState.linesSeen += 1; this.sessionState.wordsSeen += metrics.words; @@ -467,16 +302,19 @@ export class ImmersionTrackerService { sessionId: this.sessionState.sessionId, sampleMs: Date.now(), lineIndex: this.sessionState.currentLineIndex, - segmentStartMs: this.secToMs(startSec), - segmentEndMs: this.secToMs(endSec), + segmentStartMs: secToMs(startSec), + segmentEndMs: secToMs(endSec), wordsDelta: metrics.words, cardsDelta: 0, eventType: EVENT_SUBTITLE_LINE, - payloadJson: this.sanitizePayload({ - event: 'subtitle-line', - text: cleaned, - words: metrics.words, - }), + payloadJson: sanitizePayload( + { + event: 'subtitle-line', + text: cleaned, + words: metrics.words, + }, + this.maxPayloadBytes, + ), }); } @@ -515,10 +353,13 @@ export class ImmersionTrackerService { cardsDelta: 0, segmentStartMs: this.sessionState.lastMediaMs, segmentEndMs: mediaMs, - payloadJson: this.sanitizePayload({ - fromMs: this.sessionState.lastMediaMs, - toMs: mediaMs, - }), + payloadJson: sanitizePayload( + { + fromMs: this.sessionState.lastMediaMs, + toMs: mediaMs, + }, + this.maxPayloadBytes, + ), }); } else if (mediaDeltaMs < 0) { this.sessionState.seekBackwardCount += 1; @@ -532,10 +373,13 @@ export class ImmersionTrackerService { cardsDelta: 0, segmentStartMs: this.sessionState.lastMediaMs, segmentEndMs: mediaMs, - payloadJson: this.sanitizePayload({ - fromMs: this.sessionState.lastMediaMs, - toMs: mediaMs, - }), + payloadJson: sanitizePayload( + { + fromMs: this.sessionState.lastMediaMs, + toMs: mediaMs, + }, + this.maxPayloadBytes, + ), }); } } @@ -562,7 +406,7 @@ export class ImmersionTrackerService { eventType: EVENT_PAUSE_START, cardsDelta: 0, wordsDelta: 0, - payloadJson: this.sanitizePayload({ paused: true }), + payloadJson: sanitizePayload({ paused: true }, this.maxPayloadBytes), }); } else { if (this.sessionState.lastPauseStartMs) { @@ -577,7 +421,7 @@ export class ImmersionTrackerService { eventType: EVENT_PAUSE_END, cardsDelta: 0, wordsDelta: 0, - payloadJson: this.sanitizePayload({ paused: false }), + payloadJson: sanitizePayload({ paused: false }, this.maxPayloadBytes), }); } @@ -598,9 +442,12 @@ export class ImmersionTrackerService { eventType: EVENT_LOOKUP, cardsDelta: 0, wordsDelta: 0, - payloadJson: this.sanitizePayload({ - hit, - }), + payloadJson: sanitizePayload( + { + hit, + }, + this.maxPayloadBytes, + ), }); } @@ -615,7 +462,7 @@ export class ImmersionTrackerService { eventType: EVENT_CARD_MINED, wordsDelta: 0, cardsDelta: count, - payloadJson: this.sanitizePayload({ cardsMined: count }), + payloadJson: sanitizePayload({ cardsMined: count }, this.maxPayloadBytes), }); } @@ -630,21 +477,22 @@ export class ImmersionTrackerService { eventType: EVENT_MEDIA_BUFFER, cardsDelta: 0, wordsDelta: 0, - payloadJson: this.sanitizePayload({ - buffer: true, - }), + payloadJson: sanitizePayload( + { + buffer: true, + }, + this.maxPayloadBytes, + ), }); } private recordWrite(write: QueuedWrite): void { if (this.isDestroyed) return; - if (this.queue.length >= this.queueCap) { - const overflow = this.queue.length - this.queueCap + 1; - this.queue.splice(0, overflow); - this.droppedWriteCount += overflow; - this.logger.warn(`Immersion tracker queue overflow; dropped ${overflow} oldest writes`); + const { dropped } = enqueueWrite(this.queue, write, this.queueCap); + if (dropped > 0) { + this.droppedWriteCount += dropped; + this.logger.warn(`Immersion tracker queue overflow; dropped ${dropped} oldest writes`); } - this.queue.push(write); this.lastQueueWriteAtMs = Date.now(); if (write.kind === 'event' || this.queue.length >= this.batchSize) { this.scheduleFlush(0); @@ -909,18 +757,6 @@ export class ImmersionTrackerService { `); } - private resolveBoundedInt( - value: number | undefined, - fallback: number, - min: number, - max: number, - ): number { - if (!Number.isFinite(value)) return fallback; - const candidate = Math.floor(value as number); - if (candidate < min || candidate > max) return fallback; - return candidate; - } - private scheduleMaintenance(): void { this.maintenanceTimer = setInterval(() => { this.runMaintenance(); @@ -934,21 +770,13 @@ export class ImmersionTrackerService { this.flushTelemetry(true); this.flushNow(); const nowMs = Date.now(); - const eventCutoff = nowMs - this.eventsRetentionMs; - const telemetryCutoff = nowMs - this.telemetryRetentionMs; - const dailyCutoff = nowMs - this.dailyRollupRetentionMs; - const monthlyCutoff = nowMs - this.monthlyRollupRetentionMs; - const dayCutoff = Math.floor(dailyCutoff / 86_400_000); - const monthCutoff = this.toMonthKey(monthlyCutoff); - - this.db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff); - this.db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff); - this.db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff); - this.db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff); - this.db - .prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`) - .run(telemetryCutoff); - this.runRollupMaintenance(); + pruneRetention(this.db, nowMs, { + eventsRetentionMs: this.eventsRetentionMs, + telemetryRetentionMs: this.telemetryRetentionMs, + dailyRollupRetentionMs: this.dailyRollupRetentionMs, + monthlyRollupRetentionMs: this.monthlyRollupRetentionMs, + }); + runRollupMaintenance(this.db); if (nowMs - this.lastVacuumMs >= this.vacuumIntervalMs && !this.writeLock.locked) { this.db.exec('VACUUM'); @@ -964,96 +792,14 @@ export class ImmersionTrackerService { } private runRollupMaintenance(): void { - this.db.exec(` - INSERT OR REPLACE INTO imm_daily_rollups ( - rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, - total_words_seen, total_tokens_seen, total_cards, cards_per_hour, - words_per_min, lookup_hit_rate - ) - SELECT - CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day, - s.video_id AS video_id, - COUNT(DISTINCT s.session_id) AS total_sessions, - COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min, - COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen, - COALESCE(SUM(t.words_seen), 0) AS total_words_seen, - COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen, - COALESCE(SUM(t.cards_mined), 0) AS total_cards, - CASE - WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 - THEN (COALESCE(SUM(t.cards_mined), 0) * 60.0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) - ELSE NULL - END AS cards_per_hour, - CASE - WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 - THEN COALESCE(SUM(t.words_seen), 0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) - ELSE NULL - END AS words_per_min, - CASE - WHEN COALESCE(SUM(t.lookup_count), 0) > 0 - THEN CAST(COALESCE(SUM(t.lookup_hits), 0) AS REAL) / CAST(SUM(t.lookup_count) AS REAL) - ELSE NULL - END AS lookup_hit_rate - FROM imm_sessions s - JOIN imm_session_telemetry t - ON t.session_id = s.session_id - GROUP BY rollup_day, s.video_id - `); - - this.db.exec(` - INSERT OR REPLACE INTO imm_monthly_rollups ( - rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, - total_words_seen, total_tokens_seen, total_cards - ) - SELECT - CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month, - s.video_id AS video_id, - COUNT(DISTINCT s.session_id) AS total_sessions, - COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min, - COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen, - COALESCE(SUM(t.words_seen), 0) AS total_words_seen, - COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen, - COALESCE(SUM(t.cards_mined), 0) AS total_cards - FROM imm_sessions s - JOIN imm_session_telemetry t - ON t.session_id = s.session_id - GROUP BY rollup_month, s.video_id - `); - } - - private toMonthKey(timestampMs: number): number { - const monthDate = new Date(timestampMs); - return monthDate.getUTCFullYear() * 100 + monthDate.getUTCMonth() + 1; + runRollupMaintenance(this.db); } private startSession(videoId: number, startedAtMs?: number): void { const nowMs = startedAtMs ?? Date.now(); const result = this.startSessionStatement(videoId, nowMs); const sessionId = Number(result.lastInsertRowid); - this.sessionState = { - sessionId, - videoId, - startedAtMs: nowMs, - currentLineIndex: 0, - totalWatchedMs: 0, - activeWatchedMs: 0, - linesSeen: 0, - wordsSeen: 0, - tokensSeen: 0, - cardsMined: 0, - lookupCount: 0, - lookupHits: 0, - pauseCount: 0, - pauseMs: 0, - seekForwardCount: 0, - seekBackwardCount: 0, - mediaBufferEvents: 0, - lastWallClockMs: 0, - lastMediaMs: null, - lastPauseStartMs: null, - isPaused: false, - pendingTelemetry: true, - }; + this.sessionState = createInitialSessionState(sessionId, videoId, nowMs); this.recordWrite({ kind: 'telemetry', sessionId, @@ -1232,7 +978,7 @@ export class ImmersionTrackerService { const stat = await fs.promises.stat(mediaPath); return { sourceType: SOURCE_TYPE_LOCAL, - canonicalTitle: this.deriveCanonicalTitle(mediaPath), + canonicalTitle: deriveCanonicalTitle(mediaPath), durationMs: info.durationMs || 0, fileSizeBytes: Number.isFinite(stat.size) ? stat.size : null, codecId: info.codecId ?? null, @@ -1289,10 +1035,10 @@ export class ImmersionTrackerService { child.stderr.on('data', (chunk) => { errorOutput += chunk.toString('utf-8'); }); - child.on('error', () => resolve(this.emptyMetadata())); + child.on('error', () => resolve(emptyMetadata())); child.on('close', () => { if (errorOutput && output.length === 0) { - resolve(this.emptyMetadata()); + resolve(emptyMetadata()); return; } @@ -1323,14 +1069,14 @@ export class ImmersionTrackerService { for (const stream of parsed.streams ?? []) { if (stream.codec_type === 'video') { - widthPx = this.toNullableInt(stream.width); - heightPx = this.toNullableInt(stream.height); - fpsX100 = this.parseFps(stream.avg_frame_rate); - codecId = this.hashToCode(stream.codec_tag_string); + widthPx = toNullableInt(stream.width); + heightPx = toNullableInt(stream.height); + fpsX100 = parseFps(stream.avg_frame_rate); + codecId = hashToCode(stream.codec_tag_string); containerId = 0; } if (stream.codec_type === 'audio') { - audioCodecId = this.hashToCode(stream.codec_tag_string); + audioCodecId = hashToCode(stream.codec_tag_string); if (audioCodecId && audioCodecId > 0) { break; } @@ -1348,119 +1094,12 @@ export class ImmersionTrackerService { audioCodecId, }); } catch { - resolve(this.emptyMetadata()); + resolve(emptyMetadata()); } }); }); } - private emptyMetadata(): { - durationMs: number | null; - codecId: number | null; - containerId: number | null; - widthPx: number | null; - heightPx: number | null; - fpsX100: number | null; - bitrateKbps: number | null; - audioCodecId: number | null; - } { - return { - durationMs: null, - codecId: null, - containerId: null, - widthPx: null, - heightPx: null, - fpsX100: null, - bitrateKbps: null, - audioCodecId: null, - }; - } - - private parseFps(value?: string): number | null { - if (!value || typeof value !== 'string') return null; - const [num, den] = value.split('/'); - const n = Number(num); - const d = Number(den); - if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null; - const fps = n / d; - return Number.isFinite(fps) ? Math.round(fps * 100) : null; - } - - private hashToCode(input?: string): number | null { - if (!input) return null; - let hash = 0; - for (let i = 0; i < input.length; i += 1) { - hash = (hash * 31 + input.charCodeAt(i)) & 0x7fffffff; - } - return hash || null; - } - - private sanitizePayload(payload: Record): string { - const json = JSON.stringify(payload); - return json.length <= this.maxPayloadBytes ? json : JSON.stringify({ truncated: true }); - } - - private calculateTextMetrics(value: string): { - words: number; - tokens: number; - } { - const words = value.split(/\s+/).filter(Boolean).length; - const cjkCount = value.match(/[\u3040-\u30ff\u4e00-\u9fff]/g)?.length ?? 0; - const tokens = Math.max(words, cjkCount); - return { words, tokens }; - } - - private secToMs(seconds: number): number { - const coerced = Number(seconds); - if (!Number.isFinite(coerced)) return 0; - return Math.round(coerced * 1000); - } - - private normalizeMediaPath(mediaPath: string | null): string { - if (!mediaPath || !mediaPath.trim()) return ''; - return mediaPath.trim(); - } - - private normalizeText(value: string | null | undefined): string { - if (!value) return ''; - return value.trim().replace(/\s+/g, ' '); - } - - private buildVideoKey(mediaPath: string, sourceType: number): string { - if (sourceType === SOURCE_TYPE_REMOTE) { - return `remote:${mediaPath}`; - } - return `local:${mediaPath}`; - } - - private isRemoteSource(mediaPath: string): boolean { - return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath); - } - - private deriveCanonicalTitle(mediaPath: string): string { - if (this.isRemoteSource(mediaPath)) { - try { - const parsed = new URL(mediaPath); - const parts = parsed.pathname.split('/').filter(Boolean); - if (parts.length > 0) { - const leaf = decodeURIComponent(parts[parts.length - 1]!); - return this.normalizeText(leaf.replace(/\.[^/.]+$/, '')); - } - return this.normalizeText(parsed.hostname) || 'unknown'; - } catch { - return this.normalizeText(mediaPath); - } - } - - const filename = path.basename(mediaPath); - return this.normalizeText(filename.replace(/\.[^/.]+$/, '')); - } - - private toNullableInt(value: number | null | undefined): number | null { - if (value === null || value === undefined || !Number.isFinite(value)) return null; - return value; - } - private updateVideoTitleForActiveSession(canonicalTitle: string): void { if (!this.sessionState) return; this.db diff --git a/src/core/services/immersion-tracker/maintenance.ts b/src/core/services/immersion-tracker/maintenance.ts new file mode 100644 index 0000000..406e7ca --- /dev/null +++ b/src/core/services/immersion-tracker/maintenance.ts @@ -0,0 +1,90 @@ +import type { DatabaseSync } from 'node:sqlite'; + +export function toMonthKey(timestampMs: number): number { + const monthDate = new Date(timestampMs); + return monthDate.getUTCFullYear() * 100 + monthDate.getUTCMonth() + 1; +} + +export function pruneRetention( + db: DatabaseSync, + nowMs: number, + policy: { + eventsRetentionMs: number; + telemetryRetentionMs: number; + dailyRollupRetentionMs: number; + monthlyRollupRetentionMs: number; + }, +): void { + const eventCutoff = nowMs - policy.eventsRetentionMs; + const telemetryCutoff = nowMs - policy.telemetryRetentionMs; + const dailyCutoff = nowMs - policy.dailyRollupRetentionMs; + const monthlyCutoff = nowMs - policy.monthlyRollupRetentionMs; + const dayCutoff = Math.floor(dailyCutoff / 86_400_000); + const monthCutoff = toMonthKey(monthlyCutoff); + + db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff); + db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff); + db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff); + db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff); + db.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`).run( + telemetryCutoff, + ); +} + +export function runRollupMaintenance(db: DatabaseSync): void { + db.exec(` + INSERT OR REPLACE INTO imm_daily_rollups ( + rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, + total_words_seen, total_tokens_seen, total_cards, cards_per_hour, + words_per_min, lookup_hit_rate + ) + SELECT + CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day, + s.video_id AS video_id, + COUNT(DISTINCT s.session_id) AS total_sessions, + COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min, + COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen, + COALESCE(SUM(t.words_seen), 0) AS total_words_seen, + COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen, + COALESCE(SUM(t.cards_mined), 0) AS total_cards, + CASE + WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 + THEN (COALESCE(SUM(t.cards_mined), 0) * 60.0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) + ELSE NULL + END AS cards_per_hour, + CASE + WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 + THEN COALESCE(SUM(t.words_seen), 0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) + ELSE NULL + END AS words_per_min, + CASE + WHEN COALESCE(SUM(t.lookup_count), 0) > 0 + THEN CAST(COALESCE(SUM(t.lookup_hits), 0) AS REAL) / CAST(SUM(t.lookup_count) AS REAL) + ELSE NULL + END AS lookup_hit_rate + FROM imm_sessions s + JOIN imm_session_telemetry t + ON t.session_id = s.session_id + GROUP BY rollup_day, s.video_id + `); + + db.exec(` + INSERT OR REPLACE INTO imm_monthly_rollups ( + rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, + total_words_seen, total_tokens_seen, total_cards + ) + SELECT + CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month, + s.video_id AS video_id, + COUNT(DISTINCT s.session_id) AS total_sessions, + COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min, + COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen, + COALESCE(SUM(t.words_seen), 0) AS total_words_seen, + COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen, + COALESCE(SUM(t.cards_mined), 0) AS total_cards + FROM imm_sessions s + JOIN imm_session_telemetry t + ON t.session_id = s.session_id + GROUP BY rollup_month, s.video_id + `); +} diff --git a/src/core/services/immersion-tracker/query.ts b/src/core/services/immersion-tracker/query.ts new file mode 100644 index 0000000..84a6554 --- /dev/null +++ b/src/core/services/immersion-tracker/query.ts @@ -0,0 +1,104 @@ +import type { DatabaseSync } from 'node:sqlite'; +import type { + ImmersionSessionRollupRow, + SessionSummaryQueryRow, + SessionTimelineRow, +} from './types'; + +export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] { + const prepared = db.prepare(` + SELECT + s.video_id AS videoId, + s.started_at_ms AS startedAtMs, + s.ended_at_ms AS endedAtMs, + COALESCE(SUM(t.total_watched_ms), 0) AS totalWatchedMs, + COALESCE(SUM(t.active_watched_ms), 0) AS activeWatchedMs, + COALESCE(SUM(t.lines_seen), 0) AS linesSeen, + COALESCE(SUM(t.words_seen), 0) AS wordsSeen, + COALESCE(SUM(t.tokens_seen), 0) AS tokensSeen, + COALESCE(SUM(t.cards_mined), 0) AS cardsMined, + COALESCE(SUM(t.lookup_count), 0) AS lookupCount, + COALESCE(SUM(t.lookup_hits), 0) AS lookupHits + FROM imm_sessions s + LEFT JOIN imm_session_telemetry t ON t.session_id = s.session_id + GROUP BY s.session_id + ORDER BY s.started_at_ms DESC + LIMIT ? + `); + return prepared.all(limit) as unknown as SessionSummaryQueryRow[]; +} + +export function getSessionTimeline( + db: DatabaseSync, + sessionId: number, + limit = 200, +): SessionTimelineRow[] { + const prepared = db.prepare(` + SELECT + sample_ms AS sampleMs, + total_watched_ms AS totalWatchedMs, + active_watched_ms AS activeWatchedMs, + lines_seen AS linesSeen, + words_seen AS wordsSeen, + tokens_seen AS tokensSeen, + cards_mined AS cardsMined + FROM imm_session_telemetry + WHERE session_id = ? + ORDER BY sample_ms DESC + LIMIT ? + `); + return prepared.all(sessionId, limit) as unknown as SessionTimelineRow[]; +} + +export function getQueryHints(db: DatabaseSync): { + totalSessions: number; + activeSessions: number; +} { + const sessions = db.prepare('SELECT COUNT(*) AS total FROM imm_sessions'); + const active = db.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL'); + const totalSessions = Number(sessions.get()?.total ?? 0); + const activeSessions = Number(active.get()?.total ?? 0); + return { totalSessions, activeSessions }; +} + +export function getDailyRollups(db: DatabaseSync, limit = 60): ImmersionSessionRollupRow[] { + const prepared = db.prepare(` + SELECT + rollup_day AS rollupDayOrMonth, + video_id AS videoId, + total_sessions AS totalSessions, + total_active_min AS totalActiveMin, + total_lines_seen AS totalLinesSeen, + total_words_seen AS totalWordsSeen, + total_tokens_seen AS totalTokensSeen, + total_cards AS totalCards, + cards_per_hour AS cardsPerHour, + words_per_min AS wordsPerMin, + lookup_hit_rate AS lookupHitRate + FROM imm_daily_rollups + ORDER BY rollup_day DESC, video_id DESC + LIMIT ? + `); + return prepared.all(limit) as unknown as ImmersionSessionRollupRow[]; +} + +export function getMonthlyRollups(db: DatabaseSync, limit = 24): ImmersionSessionRollupRow[] { + const prepared = db.prepare(` + SELECT + rollup_month AS rollupDayOrMonth, + video_id AS videoId, + total_sessions AS totalSessions, + total_active_min AS totalActiveMin, + total_lines_seen AS totalLinesSeen, + total_words_seen AS totalWordsSeen, + total_tokens_seen AS totalTokensSeen, + total_cards AS totalCards, + 0 AS cardsPerHour, + 0 AS wordsPerMin, + 0 AS lookupHitRate + FROM imm_monthly_rollups + ORDER BY rollup_month DESC, video_id DESC + LIMIT ? + `); + return prepared.all(limit) as unknown as ImmersionSessionRollupRow[]; +} diff --git a/src/core/services/immersion-tracker/queue.ts b/src/core/services/immersion-tracker/queue.ts new file mode 100644 index 0000000..c665431 --- /dev/null +++ b/src/core/services/immersion-tracker/queue.ts @@ -0,0 +1,19 @@ +import type { QueuedWrite } from './types'; + +export function enqueueWrite( + queue: QueuedWrite[], + write: QueuedWrite, + queueCap: number, +): { + dropped: number; + queueLength: number; +} { + let dropped = 0; + if (queue.length >= queueCap) { + const overflow = queue.length - queueCap + 1; + queue.splice(0, overflow); + dropped = overflow; + } + queue.push(write); + return { dropped, queueLength: queue.length }; +} diff --git a/src/core/services/immersion-tracker/reducer.ts b/src/core/services/immersion-tracker/reducer.ts new file mode 100644 index 0000000..f12deb7 --- /dev/null +++ b/src/core/services/immersion-tracker/reducer.ts @@ -0,0 +1,144 @@ +import path from 'node:path'; +import type { ProbeMetadata, SessionState } from './types'; +import { SOURCE_TYPE_REMOTE } from './types'; + +export function createInitialSessionState( + sessionId: number, + videoId: number, + startedAtMs: number, +): SessionState { + return { + sessionId, + videoId, + startedAtMs, + currentLineIndex: 0, + totalWatchedMs: 0, + activeWatchedMs: 0, + linesSeen: 0, + wordsSeen: 0, + tokensSeen: 0, + cardsMined: 0, + lookupCount: 0, + lookupHits: 0, + pauseCount: 0, + pauseMs: 0, + seekForwardCount: 0, + seekBackwardCount: 0, + mediaBufferEvents: 0, + lastWallClockMs: 0, + lastMediaMs: null, + lastPauseStartMs: null, + isPaused: false, + pendingTelemetry: true, + }; +} + +export function resolveBoundedInt( + value: number | undefined, + fallback: number, + min: number, + max: number, +): number { + if (!Number.isFinite(value)) return fallback; + const candidate = Math.floor(value as number); + if (candidate < min || candidate > max) return fallback; + return candidate; +} + +export function sanitizePayload(payload: Record, maxPayloadBytes: number): string { + const json = JSON.stringify(payload); + return json.length <= maxPayloadBytes ? json : JSON.stringify({ truncated: true }); +} + +export function calculateTextMetrics(value: string): { + words: number; + tokens: number; +} { + const words = value.split(/\s+/).filter(Boolean).length; + const cjkCount = value.match(/[\u3040-\u30ff\u4e00-\u9fff]/g)?.length ?? 0; + const tokens = Math.max(words, cjkCount); + return { words, tokens }; +} + +export function secToMs(seconds: number): number { + const coerced = Number(seconds); + if (!Number.isFinite(coerced)) return 0; + return Math.round(coerced * 1000); +} + +export function normalizeMediaPath(mediaPath: string | null): string { + if (!mediaPath || !mediaPath.trim()) return ''; + return mediaPath.trim(); +} + +export function normalizeText(value: string | null | undefined): string { + if (!value) return ''; + return value.trim().replace(/\s+/g, ' '); +} + +export function buildVideoKey(mediaPath: string, sourceType: number): string { + if (sourceType === SOURCE_TYPE_REMOTE) { + return `remote:${mediaPath}`; + } + return `local:${mediaPath}`; +} + +export function isRemoteSource(mediaPath: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath); +} + +export function deriveCanonicalTitle(mediaPath: string): string { + if (isRemoteSource(mediaPath)) { + try { + const parsed = new URL(mediaPath); + const parts = parsed.pathname.split('/').filter(Boolean); + if (parts.length > 0) { + const leaf = decodeURIComponent(parts[parts.length - 1]!); + return normalizeText(leaf.replace(/\.[^/.]+$/, '')); + } + return normalizeText(parsed.hostname) || 'unknown'; + } catch { + return normalizeText(mediaPath); + } + } + + const filename = path.basename(mediaPath); + return normalizeText(filename.replace(/\.[^/.]+$/, '')); +} + +export function parseFps(value?: string): number | null { + if (!value || typeof value !== 'string') return null; + const [num, den] = value.split('/'); + const n = Number(num); + const d = Number(den); + if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null; + const fps = n / d; + return Number.isFinite(fps) ? Math.round(fps * 100) : null; +} + +export function hashToCode(input?: string): number | null { + if (!input) return null; + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash * 31 + input.charCodeAt(i)) & 0x7fffffff; + } + return hash || null; +} + +export function emptyMetadata(): ProbeMetadata { + return { + durationMs: null, + codecId: null, + containerId: null, + widthPx: null, + heightPx: null, + fpsX100: null, + bitrateKbps: null, + audioCodecId: null, + }; +} + +export function toNullableInt(value: number | null | undefined): number | null { + if (value === null || value === undefined || !Number.isFinite(value)) return null; + return value; +} diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts new file mode 100644 index 0000000..52a9afc --- /dev/null +++ b/src/core/services/immersion-tracker/types.ts @@ -0,0 +1,167 @@ +export const SCHEMA_VERSION = 1; +export const DEFAULT_QUEUE_CAP = 1_000; +export const DEFAULT_BATCH_SIZE = 25; +export const DEFAULT_FLUSH_INTERVAL_MS = 500; +export const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000; +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; +export const DEFAULT_EVENTS_RETENTION_MS = ONE_WEEK_MS; +export const DEFAULT_VACUUM_INTERVAL_MS = ONE_WEEK_MS; +export const DEFAULT_TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +export const DEFAULT_DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000; +export const DEFAULT_MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000; +export const DEFAULT_MAX_PAYLOAD_BYTES = 256; + +export const SOURCE_TYPE_LOCAL = 1; +export const SOURCE_TYPE_REMOTE = 2; + +export const SESSION_STATUS_ACTIVE = 1; +export const SESSION_STATUS_ENDED = 2; + +export const EVENT_SUBTITLE_LINE = 1; +export const EVENT_MEDIA_BUFFER = 2; +export const EVENT_LOOKUP = 3; +export const EVENT_CARD_MINED = 4; +export const EVENT_SEEK_FORWARD = 5; +export const EVENT_SEEK_BACKWARD = 6; +export const EVENT_PAUSE_START = 7; +export const EVENT_PAUSE_END = 8; + +export interface ImmersionTrackerOptions { + dbPath: string; + policy?: ImmersionTrackerPolicy; +} + +export interface ImmersionTrackerPolicy { + queueCap?: number; + batchSize?: number; + flushIntervalMs?: number; + maintenanceIntervalMs?: number; + payloadCapBytes?: number; + retention?: { + eventsDays?: number; + telemetryDays?: number; + dailyRollupsDays?: number; + monthlyRollupsDays?: number; + vacuumIntervalDays?: number; + }; +} + +export interface TelemetryAccumulator { + totalWatchedMs: number; + activeWatchedMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; + cardsMined: number; + lookupCount: number; + lookupHits: number; + pauseCount: number; + pauseMs: number; + seekForwardCount: number; + seekBackwardCount: number; + mediaBufferEvents: number; +} + +export interface SessionState extends TelemetryAccumulator { + sessionId: number; + videoId: number; + startedAtMs: number; + currentLineIndex: number; + lastWallClockMs: number; + lastMediaMs: number | null; + lastPauseStartMs: number | null; + isPaused: boolean; + pendingTelemetry: boolean; +} + +export interface QueuedWrite { + kind: 'telemetry' | 'event'; + sessionId: number; + sampleMs?: number; + totalWatchedMs?: number; + activeWatchedMs?: number; + linesSeen?: number; + wordsSeen?: number; + tokensSeen?: number; + cardsMined?: number; + lookupCount?: number; + lookupHits?: number; + pauseCount?: number; + pauseMs?: number; + seekForwardCount?: number; + seekBackwardCount?: number; + mediaBufferEvents?: number; + eventType?: number; + lineIndex?: number | null; + segmentStartMs?: number | null; + segmentEndMs?: number | null; + wordsDelta?: number; + cardsDelta?: number; + payloadJson?: string | null; +} + +export interface VideoMetadata { + sourceType: number; + canonicalTitle: string; + durationMs: number; + fileSizeBytes: number | null; + codecId: number | null; + containerId: number | null; + widthPx: number | null; + heightPx: number | null; + fpsX100: number | null; + bitrateKbps: number | null; + audioCodecId: number | null; + hashSha256: string | null; + screenshotPath: string | null; + metadataJson: string | null; +} + +export interface SessionSummaryQueryRow { + videoId: number | null; + startedAtMs: number; + endedAtMs: number | null; + totalWatchedMs: number; + activeWatchedMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; + cardsMined: number; + lookupCount: number; + lookupHits: number; +} + +export interface SessionTimelineRow { + sampleMs: number; + totalWatchedMs: number; + activeWatchedMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; + cardsMined: number; +} + +export interface ImmersionSessionRollupRow { + rollupDayOrMonth: number; + videoId: number | null; + totalSessions: number; + totalActiveMin: number; + totalLinesSeen: number; + totalWordsSeen: number; + totalTokensSeen: number; + totalCards: number; + cardsPerHour: number | null; + wordsPerMin: number | null; + lookupHitRate: number | null; +} + +export interface ProbeMetadata { + durationMs: number | null; + codecId: number | null; + containerId: number | null; + widthPx: number | null; + heightPx: number | null; + fpsX100: number | null; + bitrateKbps: number | null; + audioCodecId: number | null; +}