diff --git a/src/anki-connect.ts b/src/anki-connect.ts
new file mode 100644
index 0000000..f4b5819
--- /dev/null
+++ b/src/anki-connect.ts
@@ -0,0 +1,234 @@
+/*
+ * SubMiner - Subtitle mining overlay for mpv
+ * Copyright (C) 2024 sudacode
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import axios, { AxiosInstance } from 'axios';
+import http from 'http';
+import https from 'https';
+import { createLogger } from './logger';
+
+const log = createLogger('anki');
+
+interface AnkiConnectRequest {
+ action: string;
+ version: number;
+ params: Record;
+}
+
+interface AnkiConnectResponse {
+ result: unknown;
+ error: string | null;
+}
+
+export class AnkiConnectClient {
+ private client: AxiosInstance;
+ private backoffMs = 200;
+ private maxBackoffMs = 5000;
+ private consecutiveFailures = 0;
+ private maxConsecutiveFailures = 5;
+
+ constructor(url: string) {
+ const httpAgent = new http.Agent({
+ keepAlive: true,
+ keepAliveMsecs: 1000,
+ maxSockets: 5,
+ maxFreeSockets: 2,
+ timeout: 10000,
+ });
+
+ const httpsAgent = new https.Agent({
+ keepAlive: true,
+ keepAliveMsecs: 1000,
+ maxSockets: 5,
+ maxFreeSockets: 2,
+ timeout: 10000,
+ });
+
+ this.client = axios.create({
+ baseURL: url,
+ timeout: 10000,
+ httpAgent,
+ httpsAgent,
+ });
+ }
+
+ private async sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
+ private isRetryableError(error: unknown): boolean {
+ if (!error || typeof error !== 'object') return false;
+
+ const code = (error as Record).code;
+ const message =
+ typeof (error as Record).message === 'string'
+ ? ((error as Record).message as string).toLowerCase()
+ : '';
+
+ return (
+ code === 'ECONNRESET' ||
+ code === 'ETIMEDOUT' ||
+ code === 'ENOTFOUND' ||
+ code === 'ECONNREFUSED' ||
+ code === 'EPIPE' ||
+ message.includes('socket hang up') ||
+ message.includes('network error') ||
+ message.includes('timeout')
+ );
+ }
+
+ async invoke(
+ action: string,
+ params: Record = {},
+ options: { timeout?: number; maxRetries?: number } = {},
+ ): Promise {
+ const maxRetries = options.maxRetries ?? 3;
+ let lastError: Error | null = null;
+
+ const isMediaUpload = action === 'storeMediaFile';
+ const requestTimeout = options.timeout || (isMediaUpload ? 30000 : 10000);
+
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ if (attempt > 0) {
+ const delay = Math.min(this.backoffMs * Math.pow(2, attempt - 1), this.maxBackoffMs);
+ log.info(`AnkiConnect retry ${attempt}/${maxRetries} after ${delay}ms delay`);
+ await this.sleep(delay);
+ }
+
+ const response = await this.client.post(
+ '',
+ {
+ action,
+ version: 6,
+ params,
+ } as AnkiConnectRequest,
+ {
+ timeout: requestTimeout,
+ },
+ );
+
+ this.consecutiveFailures = 0;
+ this.backoffMs = 200;
+
+ if (response.data.error) {
+ throw new Error(response.data.error);
+ }
+
+ return response.data.result;
+ } catch (error) {
+ lastError = error as Error;
+ this.consecutiveFailures++;
+
+ if (!this.isRetryableError(error) || attempt === maxRetries) {
+ if (this.consecutiveFailures < this.maxConsecutiveFailures) {
+ log.error(
+ `AnkiConnect error (attempt ${this.consecutiveFailures}/${this.maxConsecutiveFailures}):`,
+ lastError.message,
+ );
+ } else if (this.consecutiveFailures === this.maxConsecutiveFailures) {
+ log.error('AnkiConnect: Too many consecutive failures, suppressing further error logs');
+ }
+ throw lastError;
+ }
+ }
+ }
+
+ throw lastError || new Error('Unknown error');
+ }
+
+ async findNotes(query: string, options?: { maxRetries?: number }): Promise {
+ const result = await this.invoke('findNotes', { query }, options);
+ return (result as number[]) || [];
+ }
+
+ async notesInfo(noteIds: number[]): Promise[]> {
+ const result = await this.invoke('notesInfo', { notes: noteIds });
+ return (result as Record[]) || [];
+ }
+
+ async updateNoteFields(noteId: number, fields: Record): Promise {
+ await this.invoke('updateNoteFields', {
+ note: {
+ id: noteId,
+ fields,
+ },
+ });
+ }
+
+ async storeMediaFile(filename: string, data: Buffer): Promise {
+ const base64Data = data.toString('base64');
+ const sizeKB = Math.round(base64Data.length / 1024);
+ log.info(`Uploading media file: ${filename} (${sizeKB}KB)`);
+
+ await this.invoke(
+ 'storeMediaFile',
+ {
+ filename,
+ data: base64Data,
+ },
+ { timeout: 30000 },
+ );
+ }
+
+ async addNote(
+ deckName: string,
+ modelName: string,
+ fields: Record,
+ tags: string[] = [],
+ ): Promise {
+ const note: {
+ deckName: string;
+ modelName: string;
+ fields: Record;
+ tags?: string[];
+ } = { deckName, modelName, fields };
+ if (tags.length > 0) {
+ note.tags = tags;
+ }
+
+ const result = await this.invoke('addNote', {
+ note,
+ });
+ return result as number;
+ }
+
+ async addTags(noteIds: number[], tags: string[]): Promise {
+ if (noteIds.length === 0 || tags.length === 0) {
+ return;
+ }
+
+ await this.invoke('addTags', {
+ notes: noteIds,
+ tags: tags.join(' '),
+ });
+ }
+
+ async deleteNotes(noteIds: number[]): Promise {
+ await this.invoke('deleteNotes', { notes: noteIds });
+ }
+
+ async retrieveMediaFile(filename: string): Promise {
+ const result = await this.invoke('retrieveMediaFile', { filename });
+ return (result as string) || '';
+ }
+
+ resetBackoff(): void {
+ this.backoffMs = 200;
+ this.consecutiveFailures = 0;
+ }
+}
diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts
new file mode 100644
index 0000000..90e0c3d
--- /dev/null
+++ b/src/anki-integration.test.ts
@@ -0,0 +1,268 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+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;
+ calls: {
+ findNotes: number;
+ notesInfo: number;
+ };
+ stateDir: string;
+}
+
+function createIntegrationTestContext(
+ options: {
+ highlightEnabled?: boolean;
+ onFindNotes?: () => Promise;
+ onNotesInfo?: () => Promise;
+ stateDirPrefix?: string;
+ } = {},
+): IntegrationTestContext {
+ const calls = {
+ findNotes: 0,
+ notesInfo: 0,
+ };
+
+ const stateDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), options.stateDirPrefix ?? 'subminer-anki-integration-'),
+ );
+ const knownWordCacheStatePath = path.join(stateDir, 'known-words-cache.json');
+
+ const client = {
+ findNotes: async () => {
+ calls.findNotes += 1;
+ if (options.onFindNotes) {
+ return options.onFindNotes();
+ }
+ return [] as number[];
+ },
+ notesInfo: async () => {
+ calls.notesInfo += 1;
+ if (options.onNotesInfo) {
+ return options.onNotesInfo();
+ }
+ return [] as unknown[];
+ },
+ } as {
+ findNotes: () => Promise;
+ notesInfo: () => Promise;
+ };
+
+ const integration = new AnkiIntegration(
+ {
+ nPlusOne: {
+ highlightEnabled: options.highlightEnabled ?? true,
+ },
+ },
+ {} as never,
+ {} as never,
+ undefined,
+ undefined,
+ undefined,
+ knownWordCacheStatePath,
+ );
+
+ const integrationWithClient = integration as unknown as {
+ client: {
+ findNotes: () => Promise;
+ notesInfo: () => Promise;
+ };
+ };
+ integrationWithClient.client = client;
+
+ const privateState = integration as unknown as {
+ knownWordsScope: string;
+ knownWordsLastRefreshedAtMs: number;
+ };
+ privateState.knownWordsScope = 'is:note';
+ privateState.knownWordsLastRefreshedAtMs = Date.now();
+
+ return {
+ integration,
+ calls,
+ stateDir,
+ };
+}
+
+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();
+
+ try {
+ await ctx.integration.refreshKnownWordCache();
+
+ assert.equal(ctx.calls.findNotes, 1);
+ assert.equal(ctx.calls.notesInfo, 0);
+ } finally {
+ cleanupIntegrationTestContext(ctx);
+ }
+});
+
+test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => {
+ const ctx = createIntegrationTestContext({
+ highlightEnabled: false,
+ stateDirPrefix: 'subminer-anki-integration-disabled-',
+ });
+
+ try {
+ await ctx.integration.refreshKnownWordCache();
+
+ assert.equal(ctx.calls.findNotes, 0);
+ assert.equal(ctx.calls.notesInfo, 0);
+ } finally {
+ cleanupIntegrationTestContext(ctx);
+ }
+});
+
+test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => {
+ let releaseFindNotes: (() => void) | undefined;
+ const findNotesPromise = new Promise((resolve) => {
+ releaseFindNotes = resolve;
+ });
+
+ const ctx = createIntegrationTestContext({
+ onFindNotes: async () => {
+ await findNotesPromise;
+ return [] as number[];
+ },
+ stateDirPrefix: 'subminer-anki-integration-concurrent-',
+ });
+
+ const first = ctx.integration.refreshKnownWordCache();
+ await Promise.resolve();
+ const second = ctx.integration.refreshKnownWordCache();
+
+ if (releaseFindNotes !== undefined) {
+ releaseFindNotes();
+ }
+
+ await Promise.all([first, second]);
+
+ try {
+ assert.equal(ctx.calls.findNotes, 1);
+ assert.equal(ctx.calls.notesInfo, 0);
+ } finally {
+ 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
new file mode 100644
index 0000000..1146399
--- /dev/null
+++ b/src/anki-integration.ts
@@ -0,0 +1,1120 @@
+/*
+ * SubMiner - Subtitle mining overlay for mpv
+ * Copyright (C) 2024 sudacode
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import { AnkiConnectClient } from './anki-connect';
+import { SubtitleTimingTracker } from './subtitle-timing-tracker';
+import { MediaGenerator } from './media-generator';
+import path from 'path';
+import {
+ AnkiConnectConfig,
+ KikuDuplicateCardInfo,
+ KikuFieldGroupingChoice,
+ KikuMergePreviewResponse,
+ MpvClient,
+ NotificationOptions,
+ NPlusOneMatchMode,
+} from './types';
+import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
+import { createLogger } from './logger';
+import {
+ createUiFeedbackState,
+ beginUpdateProgress,
+ endUpdateProgress,
+ showStatusNotification,
+ withUpdateProgress,
+ UiFeedbackState,
+} from './anki-integration/ui-feedback';
+import { KnownWordCacheManager } from './anki-integration/known-word-cache';
+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';
+import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
+import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
+
+const log = createLogger('anki').child('integration');
+
+interface NoteInfo {
+ noteId: number;
+ fields: Record;
+}
+
+type CardKind = 'sentence' | 'audio';
+
+export class AnkiIntegration {
+ private client: AnkiConnectClient;
+ private mediaGenerator: MediaGenerator;
+ private timingTracker: SubtitleTimingTracker;
+ private config: AnkiConnectConfig;
+ private pollingRunner!: PollingRunner;
+ private previousNoteIds = new Set();
+ private mpvClient: MpvClient;
+ private osdCallback: ((text: string) => void) | null = null;
+ private notificationCallback: ((title: string, options: NotificationOptions) => void) | null =
+ null;
+ private updateInProgress = false;
+ private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
+ private parseWarningKeys = new Set();
+ private fieldGroupingCallback:
+ | ((data: {
+ original: KikuDuplicateCardInfo;
+ duplicate: KikuDuplicateCardInfo;
+ }) => Promise)
+ | null = null;
+ private knownWordCache: KnownWordCacheManager;
+ private cardCreationService: CardCreationService;
+ private fieldGroupingMergeCollaborator: FieldGroupingMergeCollaborator;
+ private fieldGroupingService: FieldGroupingService;
+ private noteUpdateWorkflow: NoteUpdateWorkflow;
+ private fieldGroupingWorkflow: FieldGroupingWorkflow;
+
+ constructor(
+ config: AnkiConnectConfig,
+ timingTracker: SubtitleTimingTracker,
+ mpvClient: MpvClient,
+ osdCallback?: (text: string) => void,
+ notificationCallback?: (title: string, options: NotificationOptions) => void,
+ fieldGroupingCallback?: (data: {
+ original: KikuDuplicateCardInfo;
+ duplicate: KikuDuplicateCardInfo;
+ }) => Promise,
+ knownWordCacheStatePath?: string,
+ ) {
+ this.config = this.normalizeConfig(config);
+ this.client = new AnkiConnectClient(this.config.url!);
+ this.mediaGenerator = new MediaGenerator();
+ this.timingTracker = timingTracker;
+ this.mpvClient = mpvClient;
+ this.osdCallback = osdCallback || null;
+ this.notificationCallback = notificationCallback || null;
+ this.fieldGroupingCallback = fieldGroupingCallback || null;
+ this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
+ this.pollingRunner = this.createPollingRunner();
+ this.cardCreationService = this.createCardCreationService();
+ this.fieldGroupingMergeCollaborator = this.createFieldGroupingMergeCollaborator();
+ this.fieldGroupingService = this.createFieldGroupingService();
+ this.noteUpdateWorkflow = this.createNoteUpdateWorkflow();
+ this.fieldGroupingWorkflow = this.createFieldGroupingWorkflow();
+ }
+
+ 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,
+ ...config,
+ fields: {
+ ...DEFAULT_ANKI_CONNECT_CONFIG.fields,
+ ...(config.fields ?? {}),
+ },
+ ai: {
+ ...DEFAULT_ANKI_CONNECT_CONFIG.ai,
+ ...(config.openRouter ?? {}),
+ ...(config.ai ?? {}),
+ },
+ media: {
+ ...DEFAULT_ANKI_CONNECT_CONFIG.media,
+ ...(config.media ?? {}),
+ },
+ behavior: {
+ ...DEFAULT_ANKI_CONNECT_CONFIG.behavior,
+ ...(config.behavior ?? {}),
+ },
+ metadata: {
+ ...DEFAULT_ANKI_CONNECT_CONFIG.metadata,
+ ...(config.metadata ?? {}),
+ },
+ isLapis: {
+ ...DEFAULT_ANKI_CONNECT_CONFIG.isLapis,
+ ...(config.isLapis ?? {}),
+ },
+ isKiku: {
+ ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
+ ...(config.isKiku ?? {}),
+ },
+ } as AnkiConnectConfig;
+ }
+
+ private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
+ return new KnownWordCacheManager({
+ client: {
+ findNotes: async (query, options) =>
+ (await this.client.findNotes(query, options)) as unknown,
+ notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
+ },
+ getConfig: () => this.config,
+ knownWordCacheStatePath,
+ showStatusNotification: (message: string) => this.showStatusNotification(message),
+ });
+ }
+
+ private createPollingRunner(): PollingRunner {
+ return new PollingRunner({
+ getDeck: () => this.config.deck,
+ getPollingRate: () => this.config.pollingRate || DEFAULT_ANKI_CONNECT_CONFIG.pollingRate,
+ findNotes: async (query, options) =>
+ (await this.client.findNotes(query, options)) as number[],
+ shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
+ processNewCard: (noteId) => this.processNewCard(noteId),
+ isUpdateInProgress: () => this.updateInProgress,
+ setUpdateInProgress: (value) => {
+ this.updateInProgress = value;
+ },
+ getTrackedNoteIds: () => this.previousNoteIds,
+ setTrackedNoteIds: (noteIds) => {
+ this.previousNoteIds = noteIds;
+ },
+ showStatusNotification: (message: string) => this.showStatusNotification(message),
+ logDebug: (...args) => log.debug(args[0] as string, ...args.slice(1)),
+ logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
+ logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
+ });
+ }
+
+ private createCardCreationService(): CardCreationService {
+ return new CardCreationService({
+ getConfig: () => this.config,
+ getTimingTracker: () => this.timingTracker,
+ getMpvClient: () => this.mpvClient,
+ getDeck: () => this.config.deck,
+ client: {
+ addNote: (deck, modelName, fields, tags) =>
+ this.client.addNote(deck, modelName, fields, tags),
+ addTags: (noteIds, tags) => this.client.addTags(noteIds, tags),
+ notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
+ updateNoteFields: (noteId, fields) =>
+ this.client.updateNoteFields(noteId, fields) as Promise,
+ storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
+ findNotes: async (query, options) =>
+ (await this.client.findNotes(query, options)) as number[],
+ },
+ mediaGenerator: {
+ generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
+ this.mediaGenerator.generateAudio(
+ videoPath,
+ startTime,
+ endTime,
+ audioPadding,
+ audioStreamIndex,
+ ),
+ generateScreenshot: (videoPath, timestamp, options) =>
+ this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
+ generateAnimatedImage: (videoPath, startTime, endTime, audioPadding, options) =>
+ this.mediaGenerator.generateAnimatedImage(
+ videoPath,
+ startTime,
+ endTime,
+ audioPadding,
+ options,
+ ),
+ },
+ showOsdNotification: (text: string) => this.showOsdNotification(text),
+ showStatusNotification: (message: string) => this.showStatusNotification(message),
+ showNotification: (noteId, label, errorSuffix) =>
+ this.showNotification(noteId, label, errorSuffix),
+ beginUpdateProgress: (initialMessage: string) => this.beginUpdateProgress(initialMessage),
+ endUpdateProgress: () => this.endUpdateProgress(),
+ withUpdateProgress: (initialMessage: string, action: () => Promise) =>
+ this.withUpdateProgress(initialMessage, action),
+ resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
+ this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
+ resolveNoteFieldName: (noteInfo, preferredName) =>
+ this.resolveNoteFieldName(noteInfo, preferredName),
+ extractFields: (fields) => this.extractFields(fields),
+ processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
+ setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
+ this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
+ mergeFieldValue: (existing, newValue, overwrite) =>
+ this.mergeFieldValue(existing, newValue, overwrite),
+ formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
+ this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
+ getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
+ getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
+ appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
+ isUpdateInProgress: () => this.updateInProgress,
+ setUpdateInProgress: (value) => {
+ this.updateInProgress = value;
+ },
+ trackLastAddedNoteId: (noteId) => {
+ this.previousNoteIds.add(noteId);
+ },
+ });
+ }
+
+ private createFieldGroupingService(): FieldGroupingService {
+ return new FieldGroupingService({
+ getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
+ isUpdateInProgress: () => this.updateInProgress,
+ getDeck: () => this.config.deck,
+ withUpdateProgress: (initialMessage: string, action: () => Promise) =>
+ this.withUpdateProgress(initialMessage, action),
+ showOsdNotification: (text: string) => this.showOsdNotification(text),
+ findNotes: async (query, options) =>
+ (await this.client.findNotes(query, options)) as number[],
+ notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[],
+ extractFields: (fields) => this.extractFields(fields),
+ findDuplicateNote: (expression, noteId, noteInfo) =>
+ this.findDuplicateNote(expression, noteId, noteInfo),
+ hasAllConfiguredFields: (noteInfo, configuredFieldNames) =>
+ this.hasAllConfiguredFields(noteInfo, configuredFieldNames),
+ processNewCard: (noteId, options) => this.processNewCard(noteId, options),
+ getSentenceCardImageFieldName: () => this.config.fields?.image,
+ resolveFieldName: (availableFieldNames, preferredName) =>
+ this.resolveFieldName(availableFieldNames, preferredName),
+ computeFieldGroupingMergedFields: (
+ keepNoteId,
+ deleteNoteId,
+ keepNoteInfo,
+ deleteNoteInfo,
+ includeGeneratedMedia,
+ ) =>
+ this.fieldGroupingMergeCollaborator.computeFieldGroupingMergedFields(
+ keepNoteId,
+ deleteNoteId,
+ keepNoteInfo,
+ deleteNoteInfo,
+ includeGeneratedMedia,
+ ),
+ getNoteFieldMap: (noteInfo) => this.fieldGroupingMergeCollaborator.getNoteFieldMap(noteInfo),
+ handleFieldGroupingAuto: (originalNoteId, newNoteId, newNoteInfo, expression) =>
+ this.handleFieldGroupingAuto(originalNoteId, newNoteId, newNoteInfo, expression),
+ handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
+ this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
+ });
+ }
+
+ private createNoteUpdateWorkflow(): NoteUpdateWorkflow {
+ return new NoteUpdateWorkflow({
+ client: {
+ notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
+ updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
+ storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
+ },
+ getConfig: () => this.config,
+ getCurrentSubtitleText: () => this.mpvClient.currentSubText,
+ getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
+ getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
+ appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
+ extractFields: (fields) => this.extractFields(fields),
+ findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
+ this.findDuplicateNote(expression, excludeNoteId, noteInfo),
+ handleFieldGroupingAuto: (originalNoteId, newNoteId, newNoteInfo, expression) =>
+ this.handleFieldGroupingAuto(originalNoteId, newNoteId, newNoteInfo, expression),
+ handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
+ this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
+ processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
+ resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
+ this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
+ getResolvedSentenceAudioFieldName: (noteInfo) =>
+ this.getResolvedSentenceAudioFieldName(noteInfo),
+ mergeFieldValue: (existing, newValue, overwrite) =>
+ this.mergeFieldValue(existing, newValue, overwrite),
+ generateAudioFilename: () => this.generateAudioFilename(),
+ generateAudio: () => this.generateAudio(),
+ generateImageFilename: () => this.generateImageFilename(),
+ generateImage: () => this.generateImage(),
+ formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
+ this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
+ addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
+ showNotification: (noteId, label) => this.showNotification(noteId, label),
+ showOsdNotification: (message) => this.showOsdNotification(message),
+ beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage),
+ endUpdateProgress: () => this.endUpdateProgress(),
+ logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
+ logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
+ logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
+ });
+ }
+
+ private createFieldGroupingWorkflow(): FieldGroupingWorkflow {
+ return new FieldGroupingWorkflow({
+ client: {
+ notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
+ updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
+ deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
+ },
+ getConfig: () => this.config,
+ getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
+ getCurrentSubtitleText: () => this.mpvClient.currentSubText,
+ getFieldGroupingCallback: () => this.fieldGroupingCallback,
+ computeFieldGroupingMergedFields: (
+ keepNoteId,
+ deleteNoteId,
+ keepNoteInfo,
+ deleteNoteInfo,
+ includeGeneratedMedia,
+ ) =>
+ this.fieldGroupingMergeCollaborator.computeFieldGroupingMergedFields(
+ keepNoteId,
+ deleteNoteId,
+ keepNoteInfo,
+ deleteNoteInfo,
+ includeGeneratedMedia,
+ ),
+ extractFields: (fields) => this.extractFields(fields),
+ hasFieldValue: (noteInfo, preferredFieldName) =>
+ this.hasFieldValue(noteInfo, preferredFieldName),
+ addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
+ removeTrackedNoteId: (noteId) => {
+ this.previousNoteIds.delete(noteId);
+ },
+ showStatusNotification: (message) => this.showStatusNotification(message),
+ showNotification: (noteId, label) => this.showNotification(noteId, label),
+ showOsdNotification: (message) => this.showOsdNotification(message),
+ logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
+ logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
+ truncateSentence: (sentence) => this.truncateSentence(sentence),
+ });
+ }
+
+ isKnownWord(text: string): boolean {
+ return this.knownWordCache.isKnownWord(text);
+ }
+
+ getKnownWordMatchMode(): NPlusOneMatchMode {
+ return this.config.nPlusOne?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.matchMode;
+ }
+
+ private isKnownWordCacheEnabled(): boolean {
+ return this.config.nPlusOne?.highlightEnabled === true;
+ }
+
+ private startKnownWordCacheLifecycle(): void {
+ this.knownWordCache.startLifecycle();
+ }
+
+ private stopKnownWordCacheLifecycle(): void {
+ this.knownWordCache.stopLifecycle();
+ }
+
+ private getConfiguredAnkiTags(): string[] {
+ if (!Array.isArray(this.config.tags)) {
+ return [];
+ }
+ return [...new Set(this.config.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))];
+ }
+
+ private async addConfiguredTagsToNote(noteId: number): Promise {
+ const tags = this.getConfiguredAnkiTags();
+ if (tags.length === 0) {
+ return;
+ }
+ try {
+ await this.client.addTags([noteId], tags);
+ } catch (error) {
+ log.warn('Failed to add tags to card:', (error as Error).message);
+ }
+ }
+
+ async refreshKnownWordCache(): Promise {
+ return this.knownWordCache.refresh(true);
+ }
+
+ private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void {
+ if (!this.isKnownWordCacheEnabled()) {
+ return;
+ }
+
+ this.knownWordCache.appendFromNoteInfo({
+ noteId: noteInfo.noteId,
+ fields: noteInfo.fields,
+ });
+ }
+
+ private getLapisConfig(): {
+ enabled: boolean;
+ sentenceCardModel?: string;
+ } {
+ const lapis = this.config.isLapis;
+ return {
+ enabled: lapis?.enabled === true,
+ sentenceCardModel: lapis?.sentenceCardModel,
+ };
+ }
+
+ private getKikuConfig(): {
+ enabled: boolean;
+ fieldGrouping?: 'auto' | 'manual' | 'disabled';
+ deleteDuplicateInAuto?: boolean;
+ } {
+ const kiku = this.config.isKiku;
+ return {
+ enabled: kiku?.enabled === true,
+ fieldGrouping: kiku?.fieldGrouping,
+ deleteDuplicateInAuto: kiku?.deleteDuplicateInAuto,
+ };
+ }
+
+ private getEffectiveSentenceCardConfig(): {
+ model?: string;
+ sentenceField: string;
+ audioField: string;
+ lapisEnabled: boolean;
+ kikuEnabled: boolean;
+ kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
+ kikuDeleteDuplicateInAuto: boolean;
+ } {
+ const lapis = this.getLapisConfig();
+ const kiku = this.getKikuConfig();
+
+ return {
+ model: lapis.sentenceCardModel,
+ sentenceField: 'Sentence',
+ audioField: 'SentenceAudio',
+ lapisEnabled: lapis.enabled,
+ kikuEnabled: kiku.enabled,
+ kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
+ kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
+ };
+ }
+
+ start(): void {
+ if (this.pollingRunner.isRunning) {
+ this.stop();
+ }
+
+ log.info('Starting AnkiConnect integration with polling rate:', this.config.pollingRate);
+ this.startKnownWordCacheLifecycle();
+ this.pollingRunner.start();
+ }
+
+ stop(): void {
+ this.pollingRunner.stop();
+ this.stopKnownWordCacheLifecycle();
+ log.info('Stopped AnkiConnect integration');
+ }
+
+ private async processNewCard(
+ noteId: number,
+ options?: { skipKikuFieldGrouping?: boolean },
+ ): Promise {
+ await this.noteUpdateWorkflow.execute(noteId, options);
+ }
+
+ private extractFields(fields: Record): Record {
+ const result: Record = {};
+ for (const [key, value] of Object.entries(fields)) {
+ result[key.toLowerCase()] = value.value || '';
+ }
+ return result;
+ }
+
+ private processSentence(mpvSentence: string, noteFields: Record): string {
+ if (this.config.behavior?.highlightWord === false) {
+ return mpvSentence;
+ }
+
+ const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
+ const existingSentence = noteFields[sentenceFieldName] || '';
+
+ const highlightMatch = existingSentence.match(/(.*?)<\/b>/);
+ if (!highlightMatch || !highlightMatch[1]) {
+ return mpvSentence;
+ }
+
+ const highlightedText = highlightMatch[1];
+ const index = mpvSentence.indexOf(highlightedText);
+
+ if (index === -1) {
+ return mpvSentence;
+ }
+
+ const prefix = mpvSentence.substring(0, index);
+ const suffix = mpvSentence.substring(index + highlightedText.length);
+ return `${prefix}${highlightedText}${suffix}`;
+ }
+
+ private async generateAudio(): Promise {
+ const mpvClient = this.mpvClient;
+ if (!mpvClient || !mpvClient.currentVideoPath) {
+ return null;
+ }
+
+ const videoPath = mpvClient.currentVideoPath;
+ let startTime = mpvClient.currentSubStart;
+ let endTime = mpvClient.currentSubEnd;
+
+ if (startTime === undefined || endTime === undefined) {
+ const currentTime = mpvClient.currentTimePos || 0;
+ const fallback = this.getFallbackDurationSeconds() / 2;
+ startTime = currentTime - fallback;
+ endTime = currentTime + fallback;
+ }
+
+ return this.mediaGenerator.generateAudio(
+ videoPath,
+ startTime,
+ endTime,
+ this.config.media?.audioPadding,
+ this.mpvClient.currentAudioStreamIndex,
+ );
+ }
+
+ private async generateImage(): Promise {
+ if (!this.mpvClient || !this.mpvClient.currentVideoPath) {
+ return null;
+ }
+
+ const videoPath = this.mpvClient.currentVideoPath;
+ const timestamp = this.mpvClient.currentTimePos || 0;
+
+ if (this.config.media?.imageType === 'avif') {
+ let startTime = this.mpvClient.currentSubStart;
+ let endTime = this.mpvClient.currentSubEnd;
+
+ if (startTime === undefined || endTime === undefined) {
+ const fallback = this.getFallbackDurationSeconds() / 2;
+ startTime = timestamp - fallback;
+ endTime = timestamp + fallback;
+ }
+
+ return this.mediaGenerator.generateAnimatedImage(
+ videoPath,
+ startTime,
+ endTime,
+ this.config.media?.audioPadding,
+ {
+ fps: this.config.media?.animatedFps,
+ maxWidth: this.config.media?.animatedMaxWidth,
+ maxHeight: this.config.media?.animatedMaxHeight,
+ crf: this.config.media?.animatedCrf,
+ },
+ );
+ } else {
+ return this.mediaGenerator.generateScreenshot(videoPath, timestamp, {
+ format: this.config.media?.imageFormat as 'jpg' | 'png' | 'webp',
+ quality: this.config.media?.imageQuality,
+ maxWidth: this.config.media?.imageMaxWidth,
+ maxHeight: this.config.media?.imageMaxHeight,
+ });
+ }
+ }
+
+ private formatMiscInfoPattern(fallbackFilename: string, startTimeSeconds?: number): string {
+ if (!this.config.metadata?.pattern) {
+ return '';
+ }
+
+ const currentVideoPath = this.mpvClient.currentVideoPath || '';
+ const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : '';
+ const filenameWithExt = videoFilename || fallbackFilename;
+ const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
+
+ const currentTimePos =
+ typeof startTimeSeconds === 'number' && Number.isFinite(startTimeSeconds)
+ ? startTimeSeconds
+ : this.mpvClient.currentTimePos;
+ let totalMilliseconds = 0;
+ if (Number.isFinite(currentTimePos) && currentTimePos >= 0) {
+ totalMilliseconds = Math.floor(currentTimePos * 1000);
+ } else {
+ const now = new Date();
+ totalMilliseconds =
+ now.getHours() * 3600000 +
+ now.getMinutes() * 60000 +
+ now.getSeconds() * 1000 +
+ now.getMilliseconds();
+ }
+
+ const totalSeconds = Math.floor(totalMilliseconds / 1000);
+ const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
+ const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
+ const seconds = String(totalSeconds % 60).padStart(2, '0');
+ const milliseconds = String(totalMilliseconds % 1000).padStart(3, '0');
+
+ let result = this.config.metadata?.pattern
+ .replace(/%f/g, filenameWithoutExt)
+ .replace(/%F/g, filenameWithExt)
+ .replace(/%t/g, `${hours}:${minutes}:${seconds}`)
+ .replace(/%T/g, `${hours}:${minutes}:${seconds}:${milliseconds}`)
+ .replace(/
/g, '\n');
+
+ return result;
+ }
+
+ private getFallbackDurationSeconds(): number {
+ const configured = this.config.media?.fallbackDuration;
+ if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) {
+ return configured;
+ }
+ return DEFAULT_ANKI_CONNECT_CONFIG.media.fallbackDuration;
+ }
+
+ private generateAudioFilename(): string {
+ const timestamp = Date.now();
+ return `audio_${timestamp}.mp3`;
+ }
+
+ private generateImageFilename(): string {
+ const timestamp = Date.now();
+ const ext = this.config.media?.imageType === 'avif' ? 'avif' : this.config.media?.imageFormat;
+ return `image_${timestamp}.${ext}`;
+ }
+
+ private showStatusNotification(message: string): void {
+ showStatusNotification(message, {
+ getNotificationType: () => this.config.behavior?.notificationType,
+ showOsd: (text: string) => {
+ this.showOsdNotification(text);
+ },
+ showSystemNotification: (title: string, options: NotificationOptions) => {
+ if (this.notificationCallback) {
+ this.notificationCallback(title, options);
+ }
+ },
+ });
+ }
+
+ private beginUpdateProgress(initialMessage: string): void {
+ beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
+ this.showOsdNotification(text);
+ });
+ }
+
+ private endUpdateProgress(): void {
+ endUpdateProgress(this.uiFeedbackState, (timer) => {
+ clearInterval(timer);
+ });
+ }
+
+ private async withUpdateProgress(
+ initialMessage: string,
+ action: () => Promise,
+ ): Promise {
+ return withUpdateProgress(
+ this.uiFeedbackState,
+ {
+ setUpdateInProgress: (value: boolean) => {
+ this.updateInProgress = value;
+ },
+ showOsdNotification: (text: string) => {
+ this.showOsdNotification(text);
+ },
+ },
+ initialMessage,
+ action,
+ );
+ }
+
+ private showOsdNotification(text: string): void {
+ if (this.osdCallback) {
+ this.osdCallback(text);
+ } else if (this.mpvClient && this.mpvClient.send) {
+ this.mpvClient.send({
+ command: ['show-text', text, '3000'],
+ });
+ }
+ }
+
+ private resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
+ const exact = availableFieldNames.find((name) => name === preferredName);
+ if (exact) return exact;
+
+ const lower = preferredName.toLowerCase();
+ const ci = availableFieldNames.find((name) => name.toLowerCase() === lower);
+ return ci || null;
+ }
+
+ private resolveNoteFieldName(noteInfo: NoteInfo, preferredName?: string): string | null {
+ if (!preferredName) return null;
+ return this.resolveFieldName(Object.keys(noteInfo.fields), preferredName);
+ }
+
+ private resolveConfiguredFieldName(
+ noteInfo: NoteInfo,
+ ...preferredNames: (string | undefined)[]
+ ): string | null {
+ for (const preferredName of preferredNames) {
+ const resolved = this.resolveNoteFieldName(noteInfo, preferredName);
+ if (resolved) return resolved;
+ }
+ return null;
+ }
+
+ private warnFieldParseOnce(fieldName: string, reason: string, detail?: string): void {
+ const key = `${fieldName.toLowerCase()}::${reason}`;
+ if (this.parseWarningKeys.has(key)) return;
+ this.parseWarningKeys.add(key);
+ const suffix = detail ? ` (${detail})` : '';
+ log.warn(`Field grouping parse warning [${fieldName}] ${reason}${suffix}`);
+ }
+
+ private setCardTypeFields(
+ updatedFields: Record,
+ availableFieldNames: string[],
+ cardKind: CardKind,
+ ): void {
+ const audioFlagNames = ['IsAudioCard'];
+
+ if (cardKind === 'sentence') {
+ const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
+ if (sentenceFlag) {
+ updatedFields[sentenceFlag] = 'x';
+ }
+
+ for (const audioFlagName of audioFlagNames) {
+ const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
+ if (resolved && resolved !== sentenceFlag) {
+ updatedFields[resolved] = '';
+ }
+ }
+
+ const wordAndSentenceFlag = this.resolveFieldName(
+ availableFieldNames,
+ 'IsWordAndSentenceCard',
+ );
+ if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
+ updatedFields[wordAndSentenceFlag] = '';
+ }
+ return;
+ }
+
+ const resolvedAudioFlags = Array.from(
+ new Set(
+ audioFlagNames
+ .map((name) => this.resolveFieldName(availableFieldNames, name))
+ .filter((name): name is string => Boolean(name)),
+ ),
+ );
+ const audioFlagName = resolvedAudioFlags[0] || null;
+ if (audioFlagName) {
+ updatedFields[audioFlagName] = 'x';
+ }
+ for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
+ updatedFields[extraAudioFlag] = '';
+ }
+
+ const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
+ if (sentenceFlag && sentenceFlag !== audioFlagName) {
+ updatedFields[sentenceFlag] = '';
+ }
+
+ const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
+ if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
+ updatedFields[wordAndSentenceFlag] = '';
+ }
+ }
+
+ private async showNotification(
+ noteId: number,
+ label: string | number,
+ errorSuffix?: string,
+ ): Promise {
+ const message = errorSuffix
+ ? `Updated card: ${label} (${errorSuffix})`
+ : `Updated card: ${label}`;
+
+ const type = this.config.behavior?.notificationType || 'osd';
+
+ if (type === 'osd' || type === 'both') {
+ this.showOsdNotification(message);
+ }
+
+ if ((type === 'system' || type === 'both') && this.notificationCallback) {
+ let notificationIconPath: string | undefined;
+
+ if (this.mpvClient && this.mpvClient.currentVideoPath) {
+ try {
+ const timestamp = this.mpvClient.currentTimePos || 0;
+ const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
+ this.mpvClient.currentVideoPath,
+ timestamp,
+ );
+ if (iconBuffer && iconBuffer.length > 0) {
+ notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
+ iconBuffer,
+ noteId,
+ );
+ }
+ } catch (err) {
+ log.warn('Failed to generate notification icon:', (err as Error).message);
+ }
+ }
+
+ this.notificationCallback('Anki Card Updated', {
+ body: message,
+ icon: notificationIconPath,
+ });
+
+ if (notificationIconPath) {
+ this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
+ }
+ }
+ }
+
+ private mergeFieldValue(existing: string, newValue: string, overwrite: boolean): string {
+ if (overwrite || !existing.trim()) {
+ return newValue;
+ }
+ if (this.config.behavior?.mediaInsertMode === 'prepend') {
+ return newValue + existing;
+ }
+ return existing + newValue;
+ }
+
+ /**
+ * Update the last added Anki card using subtitle blocks from clipboard.
+ * This is the manual update flow (animecards-style) when auto-update is disabled.
+ */
+ async updateLastAddedFromClipboard(clipboardText: string): Promise {
+ return this.cardCreationService.updateLastAddedFromClipboard(clipboardText);
+ }
+
+ async triggerFieldGroupingForLastAddedCard(): Promise {
+ return this.fieldGroupingService.triggerFieldGroupingForLastAddedCard();
+ }
+
+ async markLastCardAsAudioCard(): Promise {
+ return this.cardCreationService.markLastCardAsAudioCard();
+ }
+
+ async createSentenceCard(
+ sentence: string,
+ startTime: number,
+ endTime: number,
+ secondarySubText?: string,
+ ): Promise {
+ return this.cardCreationService.createSentenceCard(
+ sentence,
+ startTime,
+ endTime,
+ secondarySubText,
+ );
+ }
+
+ private async findDuplicateNote(
+ expression: string,
+ excludeNoteId: number,
+ noteInfo: NoteInfo,
+ ): Promise {
+ return findDuplicateNoteForAnkiIntegration(expression, excludeNoteId, noteInfo, {
+ findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
+ notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
+ getDeck: () => this.config.deck,
+ resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName),
+ logInfo: (message) => {
+ log.info(message);
+ },
+ logDebug: (message) => {
+ log.debug(message);
+ },
+ logWarn: (message, error) => {
+ log.warn(message, (error as Error).message);
+ },
+ });
+ }
+
+ private getPreferredSentenceAudioFieldName(): string {
+ const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
+ return sentenceCardConfig.audioField || 'SentenceAudio';
+ }
+
+ private getResolvedSentenceAudioFieldName(noteInfo: NoteInfo): string | null {
+ return (
+ this.resolveNoteFieldName(noteInfo, this.getPreferredSentenceAudioFieldName()) ||
+ this.resolveConfiguredFieldName(noteInfo, this.config.fields?.audio)
+ );
+ }
+
+ private async generateMediaForMerge(): Promise<{
+ audioField?: string;
+ audioValue?: string;
+ imageField?: string;
+ imageValue?: string;
+ miscInfoValue?: string;
+ }> {
+ const result: {
+ audioField?: string;
+ audioValue?: string;
+ imageField?: string;
+ imageValue?: string;
+ miscInfoValue?: string;
+ } = {};
+
+ if (this.config.media?.generateAudio && this.mpvClient?.currentVideoPath) {
+ try {
+ const audioFilename = this.generateAudioFilename();
+ const audioBuffer = await this.generateAudio();
+ if (audioBuffer) {
+ await this.client.storeMediaFile(audioFilename, audioBuffer);
+ result.audioField = this.getPreferredSentenceAudioFieldName();
+ result.audioValue = `[sound:${audioFilename}]`;
+ if (this.config.fields?.miscInfo) {
+ result.miscInfoValue = this.formatMiscInfoPattern(
+ audioFilename,
+ this.mpvClient.currentSubStart,
+ );
+ }
+ }
+ } catch (error) {
+ log.error('Failed to generate audio for merge:', (error as Error).message);
+ }
+ }
+
+ if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) {
+ try {
+ const imageFilename = this.generateImageFilename();
+ const imageBuffer = await this.generateImage();
+ if (imageBuffer) {
+ await this.client.storeMediaFile(imageFilename, imageBuffer);
+ result.imageField = this.config.fields?.image || DEFAULT_ANKI_CONNECT_CONFIG.fields.image;
+ result.imageValue = `
`;
+ if (this.config.fields?.miscInfo && !result.miscInfoValue) {
+ result.miscInfoValue = this.formatMiscInfoPattern(
+ imageFilename,
+ this.mpvClient.currentSubStart,
+ );
+ }
+ }
+ } catch (error) {
+ log.error('Failed to generate image for merge:', (error as Error).message);
+ }
+ }
+
+ return result;
+ }
+
+ async buildFieldGroupingPreview(
+ keepNoteId: number,
+ deleteNoteId: number,
+ deleteDuplicate: boolean,
+ ): Promise {
+ return this.fieldGroupingService.buildFieldGroupingPreview(
+ keepNoteId,
+ deleteNoteId,
+ deleteDuplicate,
+ );
+ }
+
+ private async handleFieldGroupingAuto(
+ originalNoteId: number,
+ newNoteId: number,
+ newNoteInfo: NoteInfo,
+ expression: string,
+ ): Promise {
+ void expression;
+ await this.fieldGroupingWorkflow.handleAuto(originalNoteId, newNoteId, newNoteInfo);
+ }
+
+ private async handleFieldGroupingManual(
+ originalNoteId: number,
+ newNoteId: number,
+ newNoteInfo: NoteInfo,
+ expression: string,
+ ): Promise {
+ void expression;
+ return this.fieldGroupingWorkflow.handleManual(originalNoteId, newNoteId, newNoteInfo);
+ }
+
+ private truncateSentence(sentence: string): string {
+ const clean = sentence.replace(/<[^>]*>/g, '').trim();
+ if (clean.length <= 100) return clean;
+ return clean.substring(0, 100) + '...';
+ }
+
+ private hasFieldValue(noteInfo: NoteInfo, preferredFieldName?: string): boolean {
+ const resolved = this.resolveNoteFieldName(noteInfo, preferredFieldName);
+ if (!resolved) return false;
+ return Boolean(noteInfo.fields[resolved]?.value);
+ }
+
+ private hasAllConfiguredFields(
+ noteInfo: NoteInfo,
+ configuredFieldNames: (string | undefined)[],
+ ): boolean {
+ const requiredFields = configuredFieldNames.filter((fieldName): fieldName is string =>
+ Boolean(fieldName),
+ );
+ if (requiredFields.length === 0) return true;
+ return requiredFields.every((fieldName) => this.hasFieldValue(noteInfo, fieldName));
+ }
+
+ applyRuntimeConfigPatch(patch: Partial): void {
+ const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
+ const previousPollingRate = this.config.pollingRate;
+ this.config = {
+ ...this.config,
+ ...patch,
+ nPlusOne:
+ patch.nPlusOne !== undefined
+ ? {
+ ...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne),
+ ...patch.nPlusOne,
+ }
+ : this.config.nPlusOne,
+ fields:
+ patch.fields !== undefined
+ ? { ...this.config.fields, ...patch.fields }
+ : this.config.fields,
+ media:
+ patch.media !== undefined ? { ...this.config.media, ...patch.media } : this.config.media,
+ behavior:
+ patch.behavior !== undefined
+ ? { ...this.config.behavior, ...patch.behavior }
+ : this.config.behavior,
+ metadata:
+ patch.metadata !== undefined
+ ? { ...this.config.metadata, ...patch.metadata }
+ : this.config.metadata,
+ isLapis:
+ patch.isLapis !== undefined
+ ? { ...this.config.isLapis, ...patch.isLapis }
+ : this.config.isLapis,
+ isKiku:
+ patch.isKiku !== undefined
+ ? { ...this.config.isKiku, ...patch.isKiku }
+ : this.config.isKiku,
+ };
+
+ if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) {
+ this.stopKnownWordCacheLifecycle();
+ this.knownWordCache.clearKnownWordCacheState();
+ } else {
+ this.startKnownWordCacheLifecycle();
+ }
+
+ if (
+ patch.pollingRate !== undefined &&
+ previousPollingRate !== this.config.pollingRate &&
+ this.pollingRunner.isRunning
+ ) {
+ this.pollingRunner.start();
+ }
+ }
+
+ destroy(): void {
+ this.stop();
+ this.mediaGenerator.cleanup();
+ }
+}
diff --git a/src/anki-integration/ai.ts b/src/anki-integration/ai.ts
new file mode 100644
index 0000000..150f7a5
--- /dev/null
+++ b/src/anki-integration/ai.ts
@@ -0,0 +1,155 @@
+import axios from 'axios';
+
+import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
+
+const DEFAULT_AI_SYSTEM_PROMPT =
+ 'You are a translation engine. Return only the translated text with no explanations.';
+
+export function extractAiText(content: unknown): string {
+ if (typeof content === 'string') {
+ return content.trim();
+ }
+ if (!Array.isArray(content)) {
+ return '';
+ }
+
+ const parts: string[] = [];
+ for (const item of content) {
+ if (
+ item &&
+ typeof item === 'object' &&
+ 'type' in item &&
+ (item as { type?: unknown }).type === 'text' &&
+ 'text' in item &&
+ typeof (item as { text?: unknown }).text === 'string'
+ ) {
+ parts.push((item as { text: string }).text);
+ }
+ }
+
+ return parts.join('').trim();
+}
+
+export function normalizeOpenAiBaseUrl(baseUrl: string): string {
+ const trimmed = baseUrl.trim().replace(/\/+$/, '');
+ if (/\/v1$/i.test(trimmed)) {
+ return trimmed;
+ }
+ return `${trimmed}/v1`;
+}
+
+export interface AiTranslateRequest {
+ sentence: string;
+ apiKey: string;
+ baseUrl?: string;
+ model?: string;
+ targetLanguage?: string;
+ systemPrompt?: string;
+}
+
+export interface AiTranslateCallbacks {
+ logWarning: (message: string) => void;
+}
+
+export interface AiSentenceTranslationInput {
+ sentence: string;
+ secondarySubText?: string;
+ config: {
+ apiKey?: string;
+ baseUrl?: string;
+ model?: string;
+ targetLanguage?: string;
+ systemPrompt?: string;
+ enabled?: boolean;
+ alwaysUseAiTranslation?: boolean;
+ };
+}
+
+export interface AiSentenceTranslationCallbacks {
+ logWarning: (message: string) => void;
+}
+
+export async function translateSentenceWithAi(
+ request: AiTranslateRequest,
+ callbacks: AiTranslateCallbacks,
+): Promise {
+ const aiConfig = DEFAULT_ANKI_CONNECT_CONFIG.ai;
+ if (!request.apiKey.trim()) {
+ return null;
+ }
+
+ const baseUrl = normalizeOpenAiBaseUrl(
+ request.baseUrl || aiConfig.baseUrl || 'https://openrouter.ai/api',
+ );
+ const model = request.model || 'openai/gpt-4o-mini';
+ const targetLanguage = request.targetLanguage || 'English';
+ const prompt = request.systemPrompt?.trim() || DEFAULT_AI_SYSTEM_PROMPT;
+
+ try {
+ const response = await axios.post(
+ `${baseUrl}/chat/completions`,
+ {
+ model,
+ temperature: 0,
+ messages: [
+ { role: 'system', content: prompt },
+ {
+ role: 'user',
+ content: `Translate this text to ${targetLanguage}:\n\n${request.sentence}`,
+ },
+ ],
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${request.apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ timeout: 15000,
+ },
+ );
+ const content = (response.data as { choices?: unknown[] })?.choices?.[0] as
+ | { message?: { content?: unknown } }
+ | undefined;
+ return extractAiText(content?.message?.content) || null;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown translation error';
+ callbacks.logWarning(`AI translation failed: ${message}`);
+ return null;
+ }
+}
+
+export async function resolveSentenceBackText(
+ input: AiSentenceTranslationInput,
+ callbacks: AiSentenceTranslationCallbacks,
+): Promise {
+ const hasSecondarySub = Boolean(input.secondarySubText?.trim());
+ let backText = input.secondarySubText?.trim() || '';
+
+ const aiConfig = {
+ ...DEFAULT_ANKI_CONNECT_CONFIG.ai,
+ ...input.config,
+ };
+ const shouldAttemptAiTranslation =
+ aiConfig.enabled === true && (aiConfig.alwaysUseAiTranslation === true || !hasSecondarySub);
+
+ if (!shouldAttemptAiTranslation) return backText;
+
+ const request: AiTranslateRequest = {
+ sentence: input.sentence,
+ apiKey: aiConfig.apiKey ?? '',
+ baseUrl: aiConfig.baseUrl,
+ model: aiConfig.model,
+ targetLanguage: aiConfig.targetLanguage,
+ systemPrompt: aiConfig.systemPrompt,
+ };
+
+ const translated = await translateSentenceWithAi(request, {
+ logWarning: (message) => callbacks.logWarning(message),
+ });
+
+ if (translated) {
+ return translated;
+ }
+
+ return hasSecondarySub ? backText : input.sentence;
+}
diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts
new file mode 100644
index 0000000..9d9c63b
--- /dev/null
+++ b/src/anki-integration/card-creation.ts
@@ -0,0 +1,717 @@
+import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
+import { AnkiConnectConfig } from '../types';
+import { createLogger } from '../logger';
+import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
+import { MpvClient } from '../types';
+import { resolveSentenceBackText } from './ai';
+
+const log = createLogger('anki').child('integration.card-creation');
+
+export interface CardCreationNoteInfo {
+ noteId: number;
+ fields: Record;
+}
+
+type CardKind = 'sentence' | 'audio';
+
+interface CardCreationClient {
+ addNote(
+ deck: string,
+ modelName: string,
+ fields: Record,
+ tags?: string[],
+ ): Promise;
+ addTags(noteIds: number[], tags: string[]): Promise;
+ notesInfo(noteIds: number[]): Promise;
+ updateNoteFields(noteId: number, fields: Record): Promise;
+ storeMediaFile(filename: string, data: Buffer): Promise;
+ findNotes(query: string, options?: { maxRetries?: number }): Promise;
+}
+
+interface CardCreationMediaGenerator {
+ generateAudio(
+ path: string,
+ startTime: number,
+ endTime: number,
+ audioPadding?: number,
+ audioStreamIndex?: number,
+ ): Promise;
+ generateScreenshot(
+ path: string,
+ timestamp: number,
+ options: {
+ format: 'jpg' | 'png' | 'webp';
+ quality?: number;
+ maxWidth?: number;
+ maxHeight?: number;
+ },
+ ): Promise;
+ generateAnimatedImage(
+ path: string,
+ startTime: number,
+ endTime: number,
+ audioPadding?: number,
+ options?: {
+ fps?: number;
+ maxWidth?: number;
+ maxHeight?: number;
+ crf?: number;
+ },
+ ): Promise;
+}
+
+interface CardCreationDeps {
+ getConfig: () => AnkiConnectConfig;
+ getTimingTracker: () => SubtitleTimingTracker;
+ getMpvClient: () => MpvClient;
+ getDeck?: () => string | undefined;
+ client: CardCreationClient;
+ mediaGenerator: CardCreationMediaGenerator;
+ showOsdNotification: (text: string) => void;
+ showStatusNotification: (message: string) => void;
+ showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise;
+ beginUpdateProgress: (initialMessage: string) => void;
+ endUpdateProgress: () => void;
+ withUpdateProgress: (initialMessage: string, action: () => Promise) => Promise;
+ resolveConfiguredFieldName: (
+ noteInfo: CardCreationNoteInfo,
+ ...preferredNames: (string | undefined)[]
+ ) => string | null;
+ resolveNoteFieldName: (noteInfo: CardCreationNoteInfo, preferredName?: string) => string | null;
+ extractFields: (fields: Record) => Record;
+ processSentence: (mpvSentence: string, noteFields: Record) => string;
+ setCardTypeFields: (
+ updatedFields: Record,
+ availableFieldNames: string[],
+ cardKind: CardKind,
+ ) => void;
+ mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
+ formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
+ getEffectiveSentenceCardConfig: () => {
+ model?: string;
+ sentenceField: string;
+ audioField: string;
+ lapisEnabled: boolean;
+ kikuEnabled: boolean;
+ kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
+ kikuDeleteDuplicateInAuto: boolean;
+ };
+ getFallbackDurationSeconds: () => number;
+ appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
+ isUpdateInProgress: () => boolean;
+ setUpdateInProgress: (value: boolean) => void;
+ trackLastAddedNoteId?: (noteId: number) => void;
+}
+
+export class CardCreationService {
+ constructor(private readonly deps: CardCreationDeps) {}
+
+ private getConfiguredAnkiTags(): string[] {
+ const tags = this.deps.getConfig().tags;
+ if (!Array.isArray(tags)) {
+ return [];
+ }
+ return [...new Set(tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))];
+ }
+
+ private async addConfiguredTagsToNote(noteId: number): Promise {
+ const tags = this.getConfiguredAnkiTags();
+ if (tags.length === 0) {
+ return;
+ }
+ try {
+ await this.deps.client.addTags([noteId], tags);
+ } catch (error) {
+ log.warn('Failed to add tags to card:', (error as Error).message);
+ }
+ }
+
+ async updateLastAddedFromClipboard(clipboardText: string): Promise {
+ try {
+ if (!clipboardText || !clipboardText.trim()) {
+ this.deps.showOsdNotification('Clipboard is empty');
+ return;
+ }
+
+ const mpvClient = this.deps.getMpvClient();
+ if (!mpvClient || !mpvClient.currentVideoPath) {
+ this.deps.showOsdNotification('No video loaded');
+ return;
+ }
+
+ const blocks = clipboardText
+ .split(/\n\s*\n/)
+ .map((block) => block.trim())
+ .filter((block) => block.length > 0);
+
+ if (blocks.length === 0) {
+ this.deps.showOsdNotification('No subtitle blocks found in clipboard');
+ return;
+ }
+
+ const timings: { startTime: number; endTime: number }[] = [];
+ const timingTracker = this.deps.getTimingTracker();
+ for (const block of blocks) {
+ const timing = timingTracker.findTiming(block);
+ if (timing) {
+ timings.push(timing);
+ }
+ }
+
+ if (timings.length === 0) {
+ this.deps.showOsdNotification('Subtitle timing not found; copy again while playing');
+ return;
+ }
+
+ const rangeStart = Math.min(...timings.map((entry) => entry.startTime));
+ let rangeEnd = Math.max(...timings.map((entry) => entry.endTime));
+
+ const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
+ if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) {
+ log.warn(
+ `Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
+ );
+ rangeEnd = rangeStart + maxMediaDuration;
+ }
+
+ this.deps.showOsdNotification('Updating card from clipboard...');
+ this.deps.beginUpdateProgress('Updating card from clipboard');
+ this.deps.setUpdateInProgress(true);
+
+ try {
+ const deck = this.deps.getDeck?.() ?? this.deps.getConfig().deck;
+ const query = deck ? `"deck:${deck}" added:1` : 'added:1';
+ const noteIds = (await this.deps.client.findNotes(query, {
+ maxRetries: 0,
+ })) as number[];
+ if (!noteIds || noteIds.length === 0) {
+ this.deps.showOsdNotification('No recently added cards found');
+ return;
+ }
+
+ const noteId = Math.max(...noteIds);
+ const notesInfoResult = (await this.deps.client.notesInfo([
+ noteId,
+ ])) as CardCreationNoteInfo[];
+ if (!notesInfoResult || notesInfoResult.length === 0) {
+ this.deps.showOsdNotification('Card not found');
+ return;
+ }
+
+ const noteInfo = notesInfoResult[0]!;
+ const fields = this.deps.extractFields(noteInfo.fields);
+ const expressionText = fields.expression || fields.word || '';
+ const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
+ const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
+
+ const sentence = blocks.join(' ');
+ const updatedFields: Record = {};
+ let updatePerformed = false;
+ const errors: string[] = [];
+ let miscInfoFilename: string | null = null;
+
+ if (sentenceField) {
+ const processedSentence = this.deps.processSentence(sentence, fields);
+ updatedFields[sentenceField] = processedSentence;
+ updatePerformed = true;
+ }
+
+ log.info(
+ `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
+ );
+
+ if (this.deps.getConfig().media?.generateAudio) {
+ try {
+ const audioFilename = this.generateAudioFilename();
+ const audioBuffer = await this.mediaGenerateAudio(
+ mpvClient.currentVideoPath,
+ rangeStart,
+ rangeEnd,
+ );
+
+ if (audioBuffer) {
+ await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
+ if (sentenceAudioField) {
+ const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
+ updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
+ existingAudio,
+ `[sound:${audioFilename}]`,
+ this.deps.getConfig().behavior?.overwriteAudio !== false,
+ );
+ }
+ miscInfoFilename = audioFilename;
+ updatePerformed = true;
+ }
+ } catch (error) {
+ log.error('Failed to generate audio:', (error as Error).message);
+ errors.push('audio');
+ }
+ }
+
+ if (this.deps.getConfig().media?.generateImage) {
+ try {
+ const imageFilename = this.generateImageFilename();
+ const imageBuffer = await this.generateImageBuffer(
+ mpvClient.currentVideoPath,
+ rangeStart,
+ rangeEnd,
+ );
+
+ if (imageBuffer) {
+ await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
+ const imageFieldName = this.deps.resolveConfiguredFieldName(
+ noteInfo,
+ this.deps.getConfig().fields?.image,
+ DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
+ );
+ if (!imageFieldName) {
+ log.warn('Image field not found on note, skipping image update');
+ } else {
+ const existingImage = noteInfo.fields[imageFieldName]?.value || '';
+ updatedFields[imageFieldName] = this.deps.mergeFieldValue(
+ existingImage,
+ `
`,
+ this.deps.getConfig().behavior?.overwriteImage !== false,
+ );
+ miscInfoFilename = imageFilename;
+ updatePerformed = true;
+ }
+ }
+ } catch (error) {
+ log.error('Failed to generate image:', (error as Error).message);
+ errors.push('image');
+ }
+ }
+
+ if (this.deps.getConfig().fields?.miscInfo) {
+ const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', rangeStart);
+ const miscInfoField = this.deps.resolveConfiguredFieldName(
+ noteInfo,
+ this.deps.getConfig().fields?.miscInfo,
+ );
+ if (miscInfo && miscInfoField) {
+ updatedFields[miscInfoField] = miscInfo;
+ updatePerformed = true;
+ }
+ }
+
+ if (updatePerformed) {
+ await this.deps.client.updateNoteFields(noteId, updatedFields);
+ await this.addConfiguredTagsToNote(noteId);
+ const label = expressionText || noteId;
+ log.info('Updated card from clipboard:', label);
+ const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
+ await this.deps.showNotification(noteId, label, errorSuffix);
+ }
+ } finally {
+ this.deps.setUpdateInProgress(false);
+ this.deps.endUpdateProgress();
+ }
+ } catch (error) {
+ log.error('Error updating card from clipboard:', (error as Error).message);
+ this.deps.showOsdNotification(`Update failed: ${(error as Error).message}`);
+ }
+ }
+
+ async markLastCardAsAudioCard(): Promise {
+ if (this.deps.isUpdateInProgress()) {
+ this.deps.showOsdNotification('Anki update already in progress');
+ return;
+ }
+
+ try {
+ const mpvClient = this.deps.getMpvClient();
+ if (!mpvClient || !mpvClient.currentVideoPath) {
+ this.deps.showOsdNotification('No video loaded');
+ return;
+ }
+
+ if (!mpvClient.currentSubText) {
+ this.deps.showOsdNotification('No current subtitle');
+ return;
+ }
+
+ let startTime = mpvClient.currentSubStart;
+ let endTime = mpvClient.currentSubEnd;
+
+ if (startTime === undefined || endTime === undefined) {
+ const currentTime = mpvClient.currentTimePos || 0;
+ const fallback = this.deps.getFallbackDurationSeconds() / 2;
+ startTime = currentTime - fallback;
+ endTime = currentTime + fallback;
+ }
+
+ const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
+ if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
+ endTime = startTime + maxMediaDuration;
+ }
+
+ this.deps.showOsdNotification('Marking card as audio card...');
+ await this.deps.withUpdateProgress('Marking audio card', async () => {
+ const deck = this.deps.getDeck?.() ?? this.deps.getConfig().deck;
+ const query = deck ? `"deck:${deck}" added:1` : 'added:1';
+ const noteIds = (await this.deps.client.findNotes(query)) as number[];
+ if (!noteIds || noteIds.length === 0) {
+ this.deps.showOsdNotification('No recently added cards found');
+ return;
+ }
+
+ const noteId = Math.max(...noteIds);
+ const notesInfoResult = (await this.deps.client.notesInfo([
+ noteId,
+ ])) as CardCreationNoteInfo[];
+ if (!notesInfoResult || notesInfoResult.length === 0) {
+ this.deps.showOsdNotification('Card not found');
+ return;
+ }
+
+ const noteInfo = notesInfoResult[0]!;
+ const fields = this.deps.extractFields(noteInfo.fields);
+ const expressionText = fields.expression || fields.word || '';
+
+ const updatedFields: Record = {};
+ const errors: string[] = [];
+ let miscInfoFilename: string | null = null;
+
+ this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio');
+
+ const sentenceField = this.deps.getConfig().fields?.sentence;
+ if (sentenceField) {
+ const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
+ updatedFields[sentenceField] = processedSentence;
+ }
+
+ const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
+ const audioFieldName = sentenceCardConfig.audioField;
+ try {
+ const audioFilename = this.generateAudioFilename();
+ const audioBuffer = await this.mediaGenerateAudio(
+ mpvClient.currentVideoPath,
+ startTime,
+ endTime,
+ );
+
+ if (audioBuffer) {
+ await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
+ updatedFields[audioFieldName] = `[sound:${audioFilename}]`;
+ miscInfoFilename = audioFilename;
+ }
+ } catch (error) {
+ log.error('Failed to generate audio for audio card:', (error as Error).message);
+ errors.push('audio');
+ }
+
+ if (this.deps.getConfig().media?.generateImage) {
+ try {
+ const imageFilename = this.generateImageFilename();
+ const imageBuffer = await this.generateImageBuffer(
+ mpvClient.currentVideoPath,
+ startTime,
+ endTime,
+ );
+
+ const imageField = this.deps.getConfig().fields?.image;
+ if (imageBuffer && imageField) {
+ await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
+ updatedFields[imageField] = `
`;
+ miscInfoFilename = imageFilename;
+ }
+ } catch (error) {
+ log.error('Failed to generate image for audio card:', (error as Error).message);
+ errors.push('image');
+ }
+ }
+
+ if (this.deps.getConfig().fields?.miscInfo) {
+ const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime);
+ const miscInfoField = this.deps.resolveConfiguredFieldName(
+ noteInfo,
+ this.deps.getConfig().fields?.miscInfo,
+ );
+ if (miscInfo && miscInfoField) {
+ updatedFields[miscInfoField] = miscInfo;
+ }
+ }
+
+ await this.deps.client.updateNoteFields(noteId, updatedFields);
+ await this.addConfiguredTagsToNote(noteId);
+ const label = expressionText || noteId;
+ log.info('Marked card as audio card:', label);
+ const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
+ await this.deps.showNotification(noteId, label, errorSuffix);
+ });
+ } catch (error) {
+ log.error('Error marking card as audio card:', (error as Error).message);
+ this.deps.showOsdNotification(`Audio card failed: ${(error as Error).message}`);
+ }
+ }
+
+ async createSentenceCard(
+ sentence: string,
+ startTime: number,
+ endTime: number,
+ secondarySubText?: string,
+ ): Promise {
+ if (this.deps.isUpdateInProgress()) {
+ this.deps.showOsdNotification('Anki update already in progress');
+ return false;
+ }
+
+ const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
+ const sentenceCardModel = sentenceCardConfig.model;
+ if (!sentenceCardModel) {
+ this.deps.showOsdNotification('sentenceCardModel not configured');
+ return false;
+ }
+
+ const mpvClient = this.deps.getMpvClient();
+ if (!mpvClient || !mpvClient.currentVideoPath) {
+ this.deps.showOsdNotification('No video loaded');
+ return false;
+ }
+
+ const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
+ if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
+ log.warn(
+ `Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
+ );
+ endTime = startTime + maxMediaDuration;
+ }
+
+ this.deps.showOsdNotification('Creating sentence card...');
+ try {
+ return await this.deps.withUpdateProgress('Creating sentence card', async () => {
+ const videoPath = mpvClient.currentVideoPath;
+ const fields: Record = {};
+ const errors: string[] = [];
+ let miscInfoFilename: string | null = null;
+
+ const sentenceField = sentenceCardConfig.sentenceField;
+ const audioFieldName = sentenceCardConfig.audioField || 'SentenceAudio';
+ const translationField = this.deps.getConfig().fields?.translation || 'SelectionText';
+ let resolvedMiscInfoField: string | null = null;
+ let resolvedSentenceAudioField: string = audioFieldName;
+ let resolvedExpressionAudioField: string | null = null;
+
+ fields[sentenceField] = sentence;
+
+ const backText = await resolveSentenceBackText(
+ {
+ sentence,
+ secondarySubText,
+ config: this.deps.getConfig().ai || {},
+ },
+ {
+ logWarning: (message: string) => log.warn(message),
+ },
+ );
+ if (backText) {
+ fields[translationField] = backText;
+ }
+
+ if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
+ fields.IsSentenceCard = 'x';
+ fields.Expression = sentence;
+ }
+
+ const deck = this.deps.getConfig().deck || 'Default';
+ let noteId: number;
+ try {
+ noteId = await this.deps.client.addNote(
+ deck,
+ sentenceCardModel,
+ fields,
+ this.getConfiguredAnkiTags(),
+ );
+ log.info('Created sentence card:', noteId);
+ this.deps.trackLastAddedNoteId?.(noteId);
+ } catch (error) {
+ log.error('Failed to create sentence card:', (error as Error).message);
+ this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`);
+ return false;
+ }
+
+ try {
+ const noteInfoResult = await this.deps.client.notesInfo([noteId]);
+ const noteInfos = noteInfoResult as CardCreationNoteInfo[];
+ if (noteInfos.length > 0) {
+ const createdNoteInfo = noteInfos[0]!;
+ this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo);
+ resolvedSentenceAudioField =
+ this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;
+ resolvedExpressionAudioField = this.deps.resolveConfiguredFieldName(
+ createdNoteInfo,
+ this.deps.getConfig().fields?.audio || 'ExpressionAudio',
+ );
+ resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
+ createdNoteInfo,
+ this.deps.getConfig().fields?.miscInfo,
+ );
+
+ const cardTypeFields: Record = {};
+ this.deps.setCardTypeFields(
+ cardTypeFields,
+ Object.keys(createdNoteInfo.fields),
+ 'sentence',
+ );
+ if (Object.keys(cardTypeFields).length > 0) {
+ await this.deps.client.updateNoteFields(noteId, cardTypeFields);
+ }
+ }
+ } catch (error) {
+ log.error('Failed to normalize sentence card type fields:', (error as Error).message);
+ errors.push('card type fields');
+ }
+
+ const mediaFields: Record = {};
+
+ try {
+ const audioFilename = this.generateAudioFilename();
+ const audioBuffer = await this.mediaGenerateAudio(videoPath, startTime, endTime);
+
+ if (audioBuffer) {
+ await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
+ const audioValue = `[sound:${audioFilename}]`;
+ mediaFields[resolvedSentenceAudioField] = audioValue;
+ if (
+ resolvedExpressionAudioField &&
+ resolvedExpressionAudioField !== resolvedSentenceAudioField
+ ) {
+ mediaFields[resolvedExpressionAudioField] = audioValue;
+ }
+ miscInfoFilename = audioFilename;
+ }
+ } catch (error) {
+ log.error('Failed to generate sentence audio:', (error as Error).message);
+ errors.push('audio');
+ }
+
+ try {
+ const imageFilename = this.generateImageFilename();
+ const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime);
+
+ const imageField = this.deps.getConfig().fields?.image;
+ if (imageBuffer && imageField) {
+ await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
+ mediaFields[imageField] = `
`;
+ miscInfoFilename = imageFilename;
+ }
+ } catch (error) {
+ log.error('Failed to generate sentence image:', (error as Error).message);
+ errors.push('image');
+ }
+
+ if (this.deps.getConfig().fields?.miscInfo) {
+ const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime);
+ if (miscInfo && resolvedMiscInfoField) {
+ mediaFields[resolvedMiscInfoField] = miscInfo;
+ }
+ }
+
+ if (Object.keys(mediaFields).length > 0) {
+ try {
+ await this.deps.client.updateNoteFields(noteId, mediaFields);
+ } catch (error) {
+ log.error('Failed to update sentence card media:', (error as Error).message);
+ errors.push('media update');
+ }
+ }
+
+ const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
+ const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
+ await this.deps.showNotification(noteId, label, errorSuffix);
+ return true;
+ });
+ } catch (error) {
+ log.error('Error creating sentence card:', (error as Error).message);
+ this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`);
+ return false;
+ }
+ }
+
+ private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
+ return (
+ this.deps.resolveNoteFieldName(
+ noteInfo,
+ this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
+ ) || this.deps.resolveConfiguredFieldName(noteInfo, this.deps.getConfig().fields?.audio)
+ );
+ }
+
+ private async mediaGenerateAudio(
+ videoPath: string,
+ startTime: number,
+ endTime: number,
+ ): Promise {
+ const mpvClient = this.deps.getMpvClient();
+ if (!mpvClient) {
+ return null;
+ }
+
+ return this.deps.mediaGenerator.generateAudio(
+ videoPath,
+ startTime,
+ endTime,
+ this.deps.getConfig().media?.audioPadding,
+ mpvClient.currentAudioStreamIndex ?? undefined,
+ );
+ }
+
+ private async generateImageBuffer(
+ videoPath: string,
+ startTime: number,
+ endTime: number,
+ ): Promise {
+ const mpvClient = this.deps.getMpvClient();
+ if (!mpvClient) {
+ return null;
+ }
+
+ const timestamp = mpvClient.currentTimePos || 0;
+
+ if (this.deps.getConfig().media?.imageType === 'avif') {
+ let imageStart = startTime;
+ let imageEnd = endTime;
+
+ if (!Number.isFinite(imageStart) || !Number.isFinite(imageEnd)) {
+ const fallback = this.deps.getFallbackDurationSeconds() / 2;
+ imageStart = timestamp - fallback;
+ imageEnd = timestamp + fallback;
+ }
+
+ return this.deps.mediaGenerator.generateAnimatedImage(
+ videoPath,
+ imageStart,
+ imageEnd,
+ this.deps.getConfig().media?.audioPadding,
+ {
+ fps: this.deps.getConfig().media?.animatedFps,
+ maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
+ maxHeight: this.deps.getConfig().media?.animatedMaxHeight,
+ crf: this.deps.getConfig().media?.animatedCrf,
+ },
+ );
+ }
+
+ return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, {
+ format: this.deps.getConfig().media?.imageFormat as 'jpg' | 'png' | 'webp',
+ quality: this.deps.getConfig().media?.imageQuality,
+ maxWidth: this.deps.getConfig().media?.imageMaxWidth,
+ maxHeight: this.deps.getConfig().media?.imageMaxHeight,
+ });
+ }
+
+ private generateAudioFilename(): string {
+ const timestamp = Date.now();
+ return `audio_${timestamp}.mp3`;
+ }
+
+ private generateImageFilename(): string {
+ const timestamp = Date.now();
+ const ext =
+ this.deps.getConfig().media?.imageType === 'avif'
+ ? 'avif'
+ : this.deps.getConfig().media?.imageFormat;
+ return `image_${timestamp}.${ext}`;
+ }
+}
diff --git a/src/anki-integration/duplicate.test.ts b/src/anki-integration/duplicate.test.ts
new file mode 100644
index 0000000..240c6b2
--- /dev/null
+++ b/src/anki-integration/duplicate.test.ts
@@ -0,0 +1,265 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { findDuplicateNote, type NoteInfo } from './duplicate';
+
+function createFieldResolver(noteInfo: NoteInfo, preferredName: string): string | null {
+ const names = Object.keys(noteInfo.fields);
+ const exact = names.find((name) => name === preferredName);
+ if (exact) return exact;
+ const lower = preferredName.toLowerCase();
+ return names.find((name) => name.toLowerCase() === lower) ?? null;
+}
+
+test('findDuplicateNote matches duplicate when candidate uses alternate word/expression field name', async () => {
+ const currentNote: NoteInfo = {
+ noteId: 100,
+ fields: {
+ Expression: { value: '食べる' },
+ },
+ };
+
+ const duplicateId = await findDuplicateNote('食べる', 100, currentNote, {
+ findNotes: async () => [100, 200],
+ notesInfo: async () => [
+ {
+ noteId: 200,
+ fields: {
+ Word: { value: '食べる' },
+ },
+ },
+ ],
+ getDeck: () => 'Japanese::Mining',
+ resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
+ logWarn: () => {},
+ });
+
+ assert.equal(duplicateId, 200);
+});
+
+test('findDuplicateNote falls back to alias field query when primary field query returns no candidates', async () => {
+ const currentNote: NoteInfo = {
+ noteId: 100,
+ fields: {
+ Expression: { value: '食べる' },
+ },
+ };
+
+ const seenQueries: string[] = [];
+ const duplicateId = await findDuplicateNote('食べる', 100, currentNote, {
+ findNotes: async (query) => {
+ seenQueries.push(query);
+ if (query.includes('"Expression:')) {
+ return [];
+ }
+ if (query.includes('"word:') || query.includes('"Word:') || query.includes('"expression:')) {
+ return [200];
+ }
+ return [];
+ },
+ notesInfo: async () => [
+ {
+ noteId: 200,
+ fields: {
+ Word: { value: '食べる' },
+ },
+ },
+ ],
+ getDeck: () => 'Japanese::Mining',
+ resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
+ logWarn: () => {},
+ });
+
+ assert.equal(duplicateId, 200);
+ assert.equal(seenQueries.length, 2);
+});
+
+test('findDuplicateNote checks both source expression/word values when both fields are present', async () => {
+ const currentNote: NoteInfo = {
+ noteId: 100,
+ fields: {
+ Expression: { value: '昨日は雨だった。' },
+ Word: { value: '雨' },
+ },
+ };
+
+ const seenQueries: string[] = [];
+ const duplicateId = await findDuplicateNote('昨日は雨だった。', 100, currentNote, {
+ findNotes: async (query) => {
+ seenQueries.push(query);
+ if (query.includes('昨日は雨だった。')) {
+ return [];
+ }
+ if (query.includes('"Word:雨"') || query.includes('"word:雨"') || query.includes('"Expression:雨"')) {
+ return [200];
+ }
+ return [];
+ },
+ notesInfo: async () => [
+ {
+ noteId: 200,
+ fields: {
+ Word: { value: '雨' },
+ },
+ },
+ ],
+ getDeck: () => 'Japanese::Mining',
+ resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
+ logWarn: () => {},
+ });
+
+ assert.equal(duplicateId, 200);
+ assert.ok(seenQueries.some((query) => query.includes('昨日は雨だった。')));
+ assert.ok(seenQueries.some((query) => query.includes('雨')));
+});
+
+test('findDuplicateNote falls back to collection-wide query when deck-scoped query has no matches', async () => {
+ const currentNote: NoteInfo = {
+ noteId: 100,
+ fields: {
+ Expression: { value: '貴様' },
+ },
+ };
+
+ const seenQueries: string[] = [];
+ const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
+ findNotes: async (query) => {
+ seenQueries.push(query);
+ if (query.includes('deck:Japanese')) {
+ return [];
+ }
+ if (query.includes('"Expression:貴様"') || query.includes('"Word:貴様"')) {
+ return [200];
+ }
+ return [];
+ },
+ notesInfo: async () => [
+ {
+ noteId: 200,
+ fields: {
+ Expression: { value: '貴様' },
+ },
+ },
+ ],
+ getDeck: () => 'Japanese::Mining',
+ resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
+ logWarn: () => {},
+ });
+
+ assert.equal(duplicateId, 200);
+ assert.ok(seenQueries.some((query) => query.includes('deck:Japanese')));
+ assert.ok(seenQueries.some((query) => !query.includes('deck:Japanese')));
+});
+
+test('findDuplicateNote falls back to plain text query when field queries miss', async () => {
+ const currentNote: NoteInfo = {
+ noteId: 100,
+ fields: {
+ Expression: { value: '貴様' },
+ },
+ };
+
+ const seenQueries: string[] = [];
+ const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
+ findNotes: async (query) => {
+ seenQueries.push(query);
+ if (query.includes('Expression:') || query.includes('Word:')) {
+ return [];
+ }
+ if (query.includes('"貴様"')) {
+ return [200];
+ }
+ return [];
+ },
+ notesInfo: async () => [
+ {
+ noteId: 200,
+ fields: {
+ Expression: { value: '貴様' },
+ },
+ },
+ ],
+ getDeck: () => 'Japanese::Mining',
+ resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
+ logWarn: () => {},
+ });
+
+ assert.equal(duplicateId, 200);
+ assert.ok(seenQueries.some((query) => query.includes('Expression:')));
+ assert.ok(seenQueries.some((query) => query.endsWith('"貴様"')));
+});
+
+test('findDuplicateNote exact compare tolerates furigana bracket markup in candidate field', async () => {
+ const currentNote: NoteInfo = {
+ noteId: 100,
+ fields: {
+ Expression: { value: '貴様' },
+ },
+ };
+
+ const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
+ findNotes: async () => [200],
+ notesInfo: async () => [
+ {
+ noteId: 200,
+ fields: {
+ Expression: { value: '貴様[きさま]' },
+ },
+ },
+ ],
+ getDeck: () => 'Japanese::Mining',
+ resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
+ logWarn: () => {},
+ });
+
+ assert.equal(duplicateId, 200);
+});
+
+test('findDuplicateNote exact compare tolerates html wrappers in candidate field', async () => {
+ const currentNote: NoteInfo = {
+ noteId: 100,
+ fields: {
+ Expression: { value: '貴様' },
+ },
+ };
+
+ const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
+ findNotes: async () => [200],
+ notesInfo: async () => [
+ {
+ noteId: 200,
+ fields: {
+ Expression: { value: '貴様' },
+ },
+ },
+ ],
+ getDeck: () => 'Japanese::Mining',
+ resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
+ logWarn: () => {},
+ });
+
+ assert.equal(duplicateId, 200);
+});
+
+test('findDuplicateNote does not disable retries on findNotes calls', async () => {
+ const currentNote: NoteInfo = {
+ noteId: 100,
+ fields: {
+ Expression: { value: '貴様' },
+ },
+ };
+
+ const seenOptions: Array<{ maxRetries?: number } | undefined> = [];
+ await findDuplicateNote('貴様', 100, currentNote, {
+ findNotes: async (_query, options) => {
+ seenOptions.push(options);
+ return [];
+ },
+ notesInfo: async () => [],
+ getDeck: () => 'Japanese::Mining',
+ resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
+ logWarn: () => {},
+ });
+
+ assert.ok(seenOptions.length > 0);
+ assert.ok(seenOptions.every((options) => options?.maxRetries !== 0));
+});
diff --git a/src/anki-integration/duplicate.ts b/src/anki-integration/duplicate.ts
new file mode 100644
index 0000000..52ed7ff
--- /dev/null
+++ b/src/anki-integration/duplicate.ts
@@ -0,0 +1,194 @@
+export interface NoteField {
+ value: string;
+}
+
+export interface NoteInfo {
+ noteId: number;
+ fields: Record;
+}
+
+export interface DuplicateDetectionDeps {
+ findNotes: (query: string, options?: { maxRetries?: number }) => Promise;
+ notesInfo: (noteIds: number[]) => Promise;
+ getDeck: () => string | null | undefined;
+ resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null;
+ logInfo?: (message: string) => void;
+ logDebug?: (message: string) => void;
+ logWarn: (message: string, error: unknown) => void;
+}
+
+export async function findDuplicateNote(
+ expression: string,
+ excludeNoteId: number,
+ noteInfo: NoteInfo,
+ deps: DuplicateDetectionDeps,
+): Promise {
+ const sourceCandidates = getDuplicateSourceCandidates(noteInfo, expression);
+ if (sourceCandidates.length === 0) return null;
+ deps.logInfo?.(
+ `[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
+ .map((entry) => `${entry.fieldName}:${entry.value}`)
+ .join('|')}`,
+ );
+
+ const deckValue = deps.getDeck();
+ const queryPrefixes = deckValue
+ ? [`"deck:${escapeAnkiSearchValue(deckValue)}" `, '']
+ : [''];
+
+ try {
+ const noteIds = new Set();
+ const executedQueries = new Set();
+ for (const queryPrefix of queryPrefixes) {
+ for (const sourceCandidate of sourceCandidates) {
+ const escapedExpression = escapeAnkiSearchValue(sourceCandidate.value);
+ const queryFieldNames = getDuplicateCandidateFieldNames(sourceCandidate.fieldName);
+ for (const queryFieldName of queryFieldNames) {
+ const escapedFieldName = escapeAnkiSearchValue(queryFieldName);
+ const query = `${queryPrefix}"${escapedFieldName}:${escapedExpression}"`;
+ if (executedQueries.has(query)) continue;
+ executedQueries.add(query);
+ const results = (await deps.findNotes(query)) as number[];
+ deps.logDebug?.(
+ `[duplicate] query(field)="${query}" hits=${Array.isArray(results) ? results.length : 0}`,
+ );
+ for (const noteId of results) {
+ noteIds.add(noteId);
+ }
+ }
+ }
+ if (noteIds.size > 0) break;
+ }
+
+ if (noteIds.size === 0) {
+ for (const queryPrefix of queryPrefixes) {
+ for (const sourceCandidate of sourceCandidates) {
+ const escapedExpression = escapeAnkiSearchValue(sourceCandidate.value);
+ const query = `${queryPrefix}"${escapedExpression}"`;
+ if (executedQueries.has(query)) continue;
+ executedQueries.add(query);
+ const results = (await deps.findNotes(query)) as number[];
+ deps.logDebug?.(
+ `[duplicate] query(text)="${query}" hits=${Array.isArray(results) ? results.length : 0}`,
+ );
+ for (const noteId of results) {
+ noteIds.add(noteId);
+ }
+ }
+ if (noteIds.size > 0) break;
+ }
+ }
+
+ return await findFirstExactDuplicateNoteId(
+ noteIds,
+ excludeNoteId,
+ sourceCandidates.map((candidate) => candidate.value),
+ deps,
+ );
+ } catch (error) {
+ deps.logWarn('Duplicate search failed:', error);
+ return null;
+ }
+}
+
+function findFirstExactDuplicateNoteId(
+ candidateNoteIds: Iterable,
+ excludeNoteId: number,
+ sourceValues: string[],
+ deps: DuplicateDetectionDeps,
+): Promise {
+ const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
+ deps.logDebug?.(`[duplicate] candidateIds=${candidates.length} exclude=${excludeNoteId}`);
+ if (candidates.length === 0) {
+ deps.logInfo?.('[duplicate] no candidates after query + exclude');
+ return Promise.resolve(null);
+ }
+
+ const normalizedValues = new Set(
+ sourceValues.map((value) => normalizeDuplicateValue(value)).filter((value) => value.length > 0),
+ );
+ if (normalizedValues.size === 0) {
+ return Promise.resolve(null);
+ }
+
+ const chunkSize = 50;
+ return (async () => {
+ for (let i = 0; i < candidates.length; i += chunkSize) {
+ const chunk = candidates.slice(i, i + chunkSize);
+ const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
+ const notesInfo = notesInfoResult as NoteInfo[];
+ for (const noteInfo of notesInfo) {
+ const candidateFieldNames = ['word', 'expression'];
+ for (const candidateFieldName of candidateFieldNames) {
+ const resolvedField = deps.resolveFieldName(noteInfo, candidateFieldName);
+ if (!resolvedField) continue;
+ const candidateValue = noteInfo.fields[resolvedField]?.value || '';
+ if (normalizedValues.has(normalizeDuplicateValue(candidateValue))) {
+ deps.logDebug?.(
+ `[duplicate] exact-match noteId=${noteInfo.noteId} field=${resolvedField}`,
+ );
+ deps.logInfo?.(`[duplicate] matched noteId=${noteInfo.noteId} field=${resolvedField}`);
+ return noteInfo.noteId;
+ }
+ }
+ }
+ }
+ deps.logInfo?.('[duplicate] no exact match in candidate notes');
+ return null;
+ })();
+}
+
+function getDuplicateCandidateFieldNames(fieldName: string): string[] {
+ const candidates = [fieldName];
+ const lower = fieldName.toLowerCase();
+ if (lower === 'word') {
+ candidates.push('expression');
+ } else if (lower === 'expression') {
+ candidates.push('word');
+ }
+ return candidates;
+}
+
+function getDuplicateSourceCandidates(
+ noteInfo: NoteInfo,
+ fallbackExpression: string,
+): Array<{ fieldName: string; value: string }> {
+ const candidates: Array<{ fieldName: string; value: string }> = [];
+ const dedupeKey = new Set();
+
+ for (const fieldName of Object.keys(noteInfo.fields)) {
+ const lower = fieldName.toLowerCase();
+ if (lower !== 'word' && lower !== 'expression') continue;
+ const value = noteInfo.fields[fieldName]?.value?.trim() ?? '';
+ if (!value) continue;
+ const key = `${lower}:${normalizeDuplicateValue(value)}`;
+ if (dedupeKey.has(key)) continue;
+ dedupeKey.add(key);
+ candidates.push({ fieldName, value });
+ }
+
+ const trimmedFallback = fallbackExpression.trim();
+ if (trimmedFallback.length > 0) {
+ const fallbackKey = `expression:${normalizeDuplicateValue(trimmedFallback)}`;
+ if (!dedupeKey.has(fallbackKey)) {
+ candidates.push({ fieldName: 'expression', value: trimmedFallback });
+ }
+ }
+
+ return candidates;
+}
+
+function normalizeDuplicateValue(value: string): string {
+ return value
+ .replace(/<[^>]*>/g, '')
+ .replace(/([^\s\[\]]+)\[[^\]]*\]/g, '$1')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function escapeAnkiSearchValue(value: string): string {
+ return value
+ .replace(/\\/g, '\\\\')
+ .replace(/"/g, '\\"')
+ .replace(/([:*?()[\]{}])/g, '\\$1');
+}
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/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts
new file mode 100644
index 0000000..08abf07
--- /dev/null
+++ b/src/anki-integration/field-grouping-workflow.test.ts
@@ -0,0 +1,114 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { FieldGroupingWorkflow } from './field-grouping-workflow';
+
+type NoteInfo = {
+ noteId: number;
+ fields: Record;
+};
+
+function createWorkflowHarness() {
+ const updates: Array<{ noteId: number; fields: Record }> = [];
+ const deleted: number[][] = [];
+ const statuses: string[] = [];
+
+ const deps = {
+ client: {
+ notesInfo: async (noteIds: number[]) =>
+ noteIds.map(
+ (noteId) =>
+ ({
+ noteId,
+ fields: {
+ Expression: { value: `word-${noteId}` },
+ Sentence: { value: `line-${noteId}` },
+ },
+ }) satisfies NoteInfo,
+ ),
+ updateNoteFields: async (noteId: number, fields: Record) => {
+ updates.push({ noteId, fields });
+ },
+ deleteNotes: async (noteIds: number[]) => {
+ deleted.push(noteIds);
+ },
+ },
+ getConfig: () => ({
+ fields: {
+ audio: 'ExpressionAudio',
+ image: 'Picture',
+ },
+ isKiku: {
+ deleteDuplicateInAuto: true,
+ },
+ }),
+ getEffectiveSentenceCardConfig: () => ({
+ sentenceField: 'Sentence',
+ audioField: 'SentenceAudio',
+ kikuDeleteDuplicateInAuto: true,
+ }),
+ getCurrentSubtitleText: () => 'subtitle-text',
+ getFieldGroupingCallback: () => null,
+ setFieldGroupingCallback: () => undefined,
+ computeFieldGroupingMergedFields: async () => ({
+ Sentence: 'merged sentence',
+ }),
+ extractFields: (fields: Record) => {
+ const out: Record = {};
+ for (const [key, value] of Object.entries(fields)) {
+ out[key.toLowerCase()] = value.value;
+ }
+ return out;
+ },
+ hasFieldValue: (_noteInfo: NoteInfo, _field?: string) => false,
+ addConfiguredTagsToNote: async () => undefined,
+ removeTrackedNoteId: () => undefined,
+ showStatusNotification: (message: string) => {
+ statuses.push(message);
+ },
+ showNotification: async () => undefined,
+ showOsdNotification: () => undefined,
+ logError: () => undefined,
+ logInfo: () => undefined,
+ truncateSentence: (value: string) => value,
+ };
+
+ return {
+ workflow: new FieldGroupingWorkflow(deps),
+ updates,
+ deleted,
+ statuses,
+ deps,
+ };
+}
+
+test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate by default', async () => {
+ const harness = createWorkflowHarness();
+
+ await harness.workflow.handleAuto(1, 2, {
+ noteId: 2,
+ fields: {
+ Expression: { value: 'word-2' },
+ Sentence: { value: 'line-2' },
+ },
+ });
+
+ assert.equal(harness.updates.length, 1);
+ assert.equal(harness.updates[0]?.noteId, 1);
+ assert.deepEqual(harness.deleted, [[2]]);
+ assert.equal(harness.statuses.length, 1);
+});
+
+test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => {
+ const harness = createWorkflowHarness();
+
+ const handled = await harness.workflow.handleManual(1, 2, {
+ noteId: 2,
+ fields: {
+ Expression: { value: 'word-2' },
+ Sentence: { value: 'line-2' },
+ },
+ });
+
+ assert.equal(handled, false);
+ assert.equal(harness.updates.length, 0);
+});
diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts
new file mode 100644
index 0000000..3576acd
--- /dev/null
+++ b/src/anki-integration/field-grouping-workflow.ts
@@ -0,0 +1,214 @@
+import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
+
+export interface FieldGroupingWorkflowNoteInfo {
+ noteId: number;
+ fields: Record;
+}
+
+export interface FieldGroupingWorkflowDeps {
+ client: {
+ notesInfo(noteIds: number[]): Promise;
+ updateNoteFields(noteId: number, fields: Record): Promise;
+ deleteNotes(noteIds: number[]): Promise;
+ };
+ getConfig: () => {
+ fields?: {
+ audio?: string;
+ image?: string;
+ };
+ };
+ getEffectiveSentenceCardConfig: () => {
+ sentenceField: string;
+ audioField: string;
+ kikuDeleteDuplicateInAuto: boolean;
+ };
+ getCurrentSubtitleText: () => string | undefined;
+ getFieldGroupingCallback:
+ | (() => Promise<
+ | ((data: {
+ original: KikuDuplicateCardInfo;
+ duplicate: KikuDuplicateCardInfo;
+ }) => Promise)
+ | null
+ >)
+ | (() =>
+ | ((data: {
+ original: KikuDuplicateCardInfo;
+ duplicate: KikuDuplicateCardInfo;
+ }) => Promise)
+ | null);
+ computeFieldGroupingMergedFields: (
+ keepNoteId: number,
+ deleteNoteId: number,
+ keepNoteInfo: FieldGroupingWorkflowNoteInfo,
+ deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
+ includeGeneratedMedia: boolean,
+ ) => Promise>;
+ extractFields: (fields: Record) => Record;
+ hasFieldValue: (noteInfo: FieldGroupingWorkflowNoteInfo, preferredFieldName?: string) => boolean;
+ addConfiguredTagsToNote: (noteId: number) => Promise;
+ removeTrackedNoteId: (noteId: number) => void;
+ showStatusNotification: (message: string) => void;
+ showNotification: (noteId: number, label: string | number) => Promise;
+ showOsdNotification: (message: string) => void;
+ logError: (message: string, ...args: unknown[]) => void;
+ logInfo: (message: string, ...args: unknown[]) => void;
+ truncateSentence: (sentence: string) => string;
+}
+
+export class FieldGroupingWorkflow {
+ constructor(private readonly deps: FieldGroupingWorkflowDeps) {}
+
+ async handleAuto(
+ originalNoteId: number,
+ newNoteId: number,
+ newNoteInfo: FieldGroupingWorkflowNoteInfo,
+ ): Promise {
+ try {
+ const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
+ await this.performMerge(
+ originalNoteId,
+ newNoteId,
+ newNoteInfo,
+ this.getExpression(newNoteInfo),
+ sentenceCardConfig.kikuDeleteDuplicateInAuto,
+ );
+ } catch (error) {
+ this.deps.logError('Field grouping auto merge failed:', (error as Error).message);
+ this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
+ }
+ }
+
+ async handleManual(
+ originalNoteId: number,
+ newNoteId: number,
+ newNoteInfo: FieldGroupingWorkflowNoteInfo,
+ ): Promise {
+ const callback = await this.resolveFieldGroupingCallback();
+ if (!callback) {
+ this.deps.showOsdNotification('Field grouping UI unavailable');
+ return false;
+ }
+
+ try {
+ const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
+ const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
+ if (!originalNotesInfo || originalNotesInfo.length === 0) {
+ return false;
+ }
+
+ const originalNoteInfo = originalNotesInfo[0]!;
+ const expression = this.getExpression(newNoteInfo) || this.getExpression(originalNoteInfo);
+
+ const choice = await callback({
+ original: this.buildDuplicateCardInfo(originalNoteInfo, expression, true),
+ duplicate: this.buildDuplicateCardInfo(newNoteInfo, expression, false),
+ });
+
+ if (choice.cancelled) {
+ this.deps.showOsdNotification('Field grouping cancelled');
+ return false;
+ }
+
+ const keepNoteId = choice.keepNoteId;
+ const deleteNoteId = choice.deleteNoteId;
+ const deleteNoteInfo = deleteNoteId === newNoteId ? newNoteInfo : originalNoteInfo;
+
+ await this.performMerge(
+ keepNoteId,
+ deleteNoteId,
+ deleteNoteInfo,
+ expression,
+ choice.deleteDuplicate,
+ );
+ return true;
+ } catch (error) {
+ this.deps.logError('Field grouping manual merge failed:', (error as Error).message);
+ this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
+ return false;
+ }
+ }
+
+ private async performMerge(
+ keepNoteId: number,
+ deleteNoteId: number,
+ deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
+ expression: string,
+ deleteDuplicate = true,
+ ): Promise {
+ const keepNotesInfoResult = await this.deps.client.notesInfo([keepNoteId]);
+ const keepNotesInfo = keepNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
+ if (!keepNotesInfo || keepNotesInfo.length === 0) {
+ this.deps.logInfo('Keep note not found:', keepNoteId);
+ return;
+ }
+
+ const keepNoteInfo = keepNotesInfo[0]!;
+ const mergedFields = await this.deps.computeFieldGroupingMergedFields(
+ keepNoteId,
+ deleteNoteId,
+ keepNoteInfo,
+ deleteNoteInfo,
+ true,
+ );
+
+ if (Object.keys(mergedFields).length > 0) {
+ await this.deps.client.updateNoteFields(keepNoteId, mergedFields);
+ await this.deps.addConfiguredTagsToNote(keepNoteId);
+ }
+
+ if (deleteDuplicate) {
+ await this.deps.client.deleteNotes([deleteNoteId]);
+ this.deps.removeTrackedNoteId(deleteNoteId);
+ }
+
+ this.deps.logInfo('Merged duplicate card:', expression, 'into note:', keepNoteId);
+ this.deps.showStatusNotification(
+ deleteDuplicate
+ ? `Merged duplicate: ${expression}`
+ : `Grouped duplicate (kept both): ${expression}`,
+ );
+ await this.deps.showNotification(keepNoteId, expression);
+ }
+
+ private buildDuplicateCardInfo(
+ noteInfo: FieldGroupingWorkflowNoteInfo,
+ fallbackExpression: string,
+ isOriginal: boolean,
+ ): KikuDuplicateCardInfo {
+ const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
+ const fields = this.deps.extractFields(noteInfo.fields);
+ return {
+ noteId: noteInfo.noteId,
+ expression: fields.expression || fields.word || fallbackExpression,
+ sentencePreview: this.deps.truncateSentence(
+ fields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] ||
+ (isOriginal ? '' : this.deps.getCurrentSubtitleText() || ''),
+ ),
+ hasAudio:
+ this.deps.hasFieldValue(noteInfo, this.deps.getConfig().fields?.audio) ||
+ this.deps.hasFieldValue(noteInfo, sentenceCardConfig.audioField),
+ hasImage: this.deps.hasFieldValue(noteInfo, this.deps.getConfig().fields?.image),
+ isOriginal,
+ };
+ }
+
+ private getExpression(noteInfo: FieldGroupingWorkflowNoteInfo): string {
+ const fields = this.deps.extractFields(noteInfo.fields);
+ return fields.expression || fields.word || '';
+ }
+
+ private async resolveFieldGroupingCallback(): Promise<
+ | ((data: {
+ original: KikuDuplicateCardInfo;
+ duplicate: KikuDuplicateCardInfo;
+ }) => Promise)
+ | null
+ > {
+ const callback = this.deps.getFieldGroupingCallback();
+ if (callback instanceof Promise) {
+ return callback;
+ }
+ return callback;
+ }
+}
diff --git a/src/anki-integration/field-grouping.ts b/src/anki-integration/field-grouping.ts
new file mode 100644
index 0000000..becb2f2
--- /dev/null
+++ b/src/anki-integration/field-grouping.ts
@@ -0,0 +1,236 @@
+import { KikuMergePreviewResponse } from '../types';
+import { createLogger } from '../logger';
+
+const log = createLogger('anki').child('integration.field-grouping');
+
+interface FieldGroupingNoteInfo {
+ noteId: number;
+ fields: Record;
+}
+
+interface FieldGroupingDeps {
+ getEffectiveSentenceCardConfig: () => {
+ model?: string;
+ sentenceField: string;
+ audioField: string;
+ lapisEnabled: boolean;
+ kikuEnabled: boolean;
+ kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
+ kikuDeleteDuplicateInAuto: boolean;
+ };
+ isUpdateInProgress: () => boolean;
+ getDeck?: () => string | undefined;
+ withUpdateProgress: (initialMessage: string, action: () => Promise) => Promise;
+ showOsdNotification: (text: string) => void;
+ findNotes: (
+ query: string,
+ options?: {
+ maxRetries?: number;
+ },
+ ) => Promise;
+ notesInfo: (noteIds: number[]) => Promise;
+ extractFields: (fields: Record) => Record;
+ findDuplicateNote: (
+ expression: string,
+ excludeNoteId: number,
+ noteInfo: FieldGroupingNoteInfo,
+ ) => Promise;
+ hasAllConfiguredFields: (
+ noteInfo: FieldGroupingNoteInfo,
+ configuredFieldNames: (string | undefined)[],
+ ) => boolean;
+ processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise;
+ getSentenceCardImageFieldName: () => string | undefined;
+ resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
+ computeFieldGroupingMergedFields: (
+ keepNoteId: number,
+ deleteNoteId: number,
+ keepNoteInfo: FieldGroupingNoteInfo,
+ deleteNoteInfo: FieldGroupingNoteInfo,
+ includeGeneratedMedia: boolean,
+ ) => Promise>;
+ getNoteFieldMap: (noteInfo: FieldGroupingNoteInfo) => Record;
+ handleFieldGroupingAuto: (
+ originalNoteId: number,
+ newNoteId: number,
+ newNoteInfo: FieldGroupingNoteInfo,
+ expression: string,
+ ) => Promise;
+ handleFieldGroupingManual: (
+ originalNoteId: number,
+ newNoteId: number,
+ newNoteInfo: FieldGroupingNoteInfo,
+ expression: string,
+ ) => Promise;
+}
+
+export class FieldGroupingService {
+ constructor(private readonly deps: FieldGroupingDeps) {}
+
+ async triggerFieldGroupingForLastAddedCard(): Promise {
+ const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
+ if (!sentenceCardConfig.kikuEnabled) {
+ this.deps.showOsdNotification('Kiku mode is not enabled');
+ return;
+ }
+ if (sentenceCardConfig.kikuFieldGrouping === 'disabled') {
+ this.deps.showOsdNotification('Kiku field grouping is disabled');
+ return;
+ }
+
+ if (this.deps.isUpdateInProgress()) {
+ this.deps.showOsdNotification('Anki update already in progress');
+ return;
+ }
+
+ try {
+ await this.deps.withUpdateProgress('Grouping duplicate cards', async () => {
+ const deck = this.deps.getDeck ? this.deps.getDeck() : undefined;
+ const query = deck ? `"deck:${deck}" added:1` : 'added:1';
+ const noteIds = await this.deps.findNotes(query);
+ if (!noteIds || noteIds.length === 0) {
+ this.deps.showOsdNotification('No recently added cards found');
+ return;
+ }
+
+ const noteId = Math.max(...noteIds);
+ const notesInfoResult = await this.deps.notesInfo([noteId]);
+ const notesInfo = notesInfoResult as FieldGroupingNoteInfo[];
+ if (!notesInfo || notesInfo.length === 0) {
+ this.deps.showOsdNotification('Card not found');
+ return;
+ }
+ const noteInfoBeforeUpdate = notesInfo[0]!;
+ const fields = this.deps.extractFields(noteInfoBeforeUpdate.fields);
+ const expressionText = fields.expression || fields.word || '';
+ if (!expressionText) {
+ this.deps.showOsdNotification('No expression/word field found');
+ return;
+ }
+
+ const duplicateNoteId = await this.deps.findDuplicateNote(
+ expressionText,
+ noteId,
+ noteInfoBeforeUpdate,
+ );
+ if (duplicateNoteId === null) {
+ this.deps.showOsdNotification('No duplicate card found');
+ return;
+ }
+
+ if (
+ !this.deps.hasAllConfiguredFields(noteInfoBeforeUpdate, [
+ this.deps.getSentenceCardImageFieldName(),
+ ])
+ ) {
+ await this.deps.processNewCard(noteId, {
+ skipKikuFieldGrouping: true,
+ });
+ }
+
+ const refreshedInfoResult = await this.deps.notesInfo([noteId]);
+ const refreshedInfo = refreshedInfoResult as FieldGroupingNoteInfo[];
+ if (!refreshedInfo || refreshedInfo.length === 0) {
+ this.deps.showOsdNotification('Card not found');
+ return;
+ }
+
+ const noteInfo = refreshedInfo[0]!;
+
+ if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
+ await this.deps.handleFieldGroupingAuto(
+ duplicateNoteId,
+ noteId,
+ noteInfo,
+ expressionText,
+ );
+ return;
+ }
+ const handled = await this.deps.handleFieldGroupingManual(
+ duplicateNoteId,
+ noteId,
+ noteInfo,
+ expressionText,
+ );
+ if (!handled) {
+ this.deps.showOsdNotification('Field grouping cancelled');
+ }
+ });
+ } catch (error) {
+ log.error('Error triggering field grouping:', (error as Error).message);
+ this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
+ }
+ }
+
+ async buildFieldGroupingPreview(
+ keepNoteId: number,
+ deleteNoteId: number,
+ deleteDuplicate: boolean,
+ ): Promise {
+ try {
+ const notesInfoResult = await this.deps.notesInfo([keepNoteId, deleteNoteId]);
+ const notesInfo = notesInfoResult as FieldGroupingNoteInfo[];
+ const keepNoteInfo = notesInfo.find((note) => note.noteId === keepNoteId);
+ const deleteNoteInfo = notesInfo.find((note) => note.noteId === deleteNoteId);
+
+ if (!keepNoteInfo || !deleteNoteInfo) {
+ return { ok: false, error: 'Could not load selected notes' };
+ }
+
+ const mergedFields = await this.deps.computeFieldGroupingMergedFields(
+ keepNoteId,
+ deleteNoteId,
+ keepNoteInfo,
+ deleteNoteInfo,
+ false,
+ );
+ const keepBefore = this.deps.getNoteFieldMap(keepNoteInfo);
+ const keepAfter = { ...keepBefore, ...mergedFields };
+ const sourceBefore = this.deps.getNoteFieldMap(deleteNoteInfo);
+
+ const compactFields: Record = {};
+ for (const fieldName of [
+ 'Sentence',
+ 'SentenceFurigana',
+ 'SentenceAudio',
+ 'Picture',
+ 'MiscInfo',
+ ]) {
+ const resolved = this.deps.resolveFieldName(Object.keys(keepAfter), fieldName);
+ if (!resolved) continue;
+ compactFields[fieldName] = keepAfter[resolved] || '';
+ }
+
+ return {
+ ok: true,
+ compact: {
+ action: {
+ keepNoteId,
+ deleteNoteId,
+ deleteDuplicate,
+ },
+ mergedFields: compactFields,
+ },
+ full: {
+ keepNote: {
+ id: keepNoteId,
+ fieldsBefore: keepBefore,
+ },
+ sourceNote: {
+ id: deleteNoteId,
+ fieldsBefore: sourceBefore,
+ },
+ result: {
+ fieldsAfter: keepAfter,
+ wouldDeleteNoteId: deleteDuplicate ? deleteNoteId : null,
+ },
+ },
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ error: `Failed to build preview: ${(error as Error).message}`,
+ };
+ }
+ }
+}
diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts
new file mode 100644
index 0000000..b693fb8
--- /dev/null
+++ b/src/anki-integration/known-word-cache.ts
@@ -0,0 +1,388 @@
+import fs from 'fs';
+import path from 'path';
+
+import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
+import { AnkiConnectConfig } from '../types';
+import { createLogger } from '../logger';
+
+const log = createLogger('anki').child('integration.known-word-cache');
+
+export interface KnownWordCacheNoteInfo {
+ noteId: number;
+ fields: Record;
+}
+
+interface KnownWordCacheState {
+ readonly version: 1;
+ readonly refreshedAtMs: number;
+ readonly scope: string;
+ readonly words: string[];
+}
+
+interface KnownWordCacheClient {
+ findNotes: (
+ query: string,
+ options?: {
+ maxRetries?: number;
+ },
+ ) => Promise;
+ notesInfo: (noteIds: number[]) => Promise;
+}
+
+interface KnownWordCacheDeps {
+ client: KnownWordCacheClient;
+ getConfig: () => AnkiConnectConfig;
+ knownWordCacheStatePath?: string;
+ showStatusNotification: (message: string) => void;
+}
+
+export class KnownWordCacheManager {
+ private knownWordsLastRefreshedAtMs = 0;
+ private knownWordsScope = '';
+ private knownWords: Set = new Set();
+ private knownWordsRefreshTimer: ReturnType | null = null;
+ private isRefreshingKnownWords = false;
+ private readonly statePath: string;
+
+ constructor(private readonly deps: KnownWordCacheDeps) {
+ this.statePath = path.normalize(
+ deps.knownWordCacheStatePath || path.join(process.cwd(), 'known-words-cache.json'),
+ );
+ }
+
+ isKnownWord(text: string): boolean {
+ if (!this.isKnownWordCacheEnabled()) {
+ return false;
+ }
+
+ const normalized = this.normalizeKnownWordForLookup(text);
+ return normalized.length > 0 ? this.knownWords.has(normalized) : false;
+ }
+
+ refresh(force = false): Promise {
+ return this.refreshKnownWords(force);
+ }
+
+ startLifecycle(): void {
+ this.stopLifecycle();
+ if (!this.isKnownWordCacheEnabled()) {
+ log.info('Known-word cache disabled; clearing local cache state');
+ this.clearKnownWordCacheState();
+ return;
+ }
+
+ const refreshMinutes = this.getKnownWordRefreshIntervalMs() / 60_000;
+ const scope = this.getKnownWordCacheScope();
+ log.info(
+ 'Known-word cache lifecycle enabled',
+ `scope=${scope}`,
+ `refreshMinutes=${refreshMinutes}`,
+ `cachePath=${this.statePath}`,
+ );
+
+ this.loadKnownWordCacheState();
+ void this.refreshKnownWords();
+ const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
+ this.knownWordsRefreshTimer = setInterval(() => {
+ void this.refreshKnownWords();
+ }, refreshIntervalMs);
+ }
+
+ stopLifecycle(): void {
+ if (this.knownWordsRefreshTimer) {
+ clearInterval(this.knownWordsRefreshTimer);
+ this.knownWordsRefreshTimer = null;
+ }
+ }
+
+ appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
+ if (!this.isKnownWordCacheEnabled()) {
+ return;
+ }
+
+ const currentScope = this.getKnownWordCacheScope();
+ if (this.knownWordsScope && this.knownWordsScope !== currentScope) {
+ this.clearKnownWordCacheState();
+ }
+ if (!this.knownWordsScope) {
+ this.knownWordsScope = currentScope;
+ }
+
+ let addedCount = 0;
+ for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) {
+ const normalized = this.normalizeKnownWordForLookup(rawWord);
+ if (!normalized || this.knownWords.has(normalized)) {
+ continue;
+ }
+ this.knownWords.add(normalized);
+ addedCount += 1;
+ }
+
+ if (addedCount > 0) {
+ if (this.knownWordsLastRefreshedAtMs <= 0) {
+ this.knownWordsLastRefreshedAtMs = Date.now();
+ }
+ this.persistKnownWordCacheState();
+ log.info(
+ 'Known-word cache updated in-session',
+ `added=${addedCount}`,
+ `scope=${currentScope}`,
+ );
+ }
+ }
+
+ clearKnownWordCacheState(): void {
+ this.knownWords = new Set();
+ this.knownWordsLastRefreshedAtMs = 0;
+ this.knownWordsScope = this.getKnownWordCacheScope();
+ try {
+ if (fs.existsSync(this.statePath)) {
+ fs.unlinkSync(this.statePath);
+ }
+ } catch (error) {
+ log.warn('Failed to clear known-word cache state:', (error as Error).message);
+ }
+ }
+
+ private async refreshKnownWords(force = false): Promise {
+ if (!this.isKnownWordCacheEnabled()) {
+ log.debug('Known-word cache refresh skipped; feature disabled');
+ return;
+ }
+ if (this.isRefreshingKnownWords) {
+ log.debug('Known-word cache refresh skipped; already refreshing');
+ return;
+ }
+ if (!force && !this.isKnownWordCacheStale()) {
+ log.debug('Known-word cache refresh skipped; cache is fresh');
+ return;
+ }
+
+ this.isRefreshingKnownWords = true;
+ try {
+ const query = this.buildKnownWordsQuery();
+ log.debug('Refreshing known-word cache', `query=${query}`);
+ const noteIds = (await this.deps.client.findNotes(query, {
+ maxRetries: 0,
+ })) as number[];
+
+ const nextKnownWords = new Set();
+ if (noteIds.length > 0) {
+ const chunkSize = 50;
+ for (let i = 0; i < noteIds.length; i += chunkSize) {
+ const chunk = noteIds.slice(i, i + chunkSize);
+ const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[];
+ const notesInfo = notesInfoResult as KnownWordCacheNoteInfo[];
+
+ for (const noteInfo of notesInfo) {
+ for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) {
+ const normalized = this.normalizeKnownWordForLookup(word);
+ if (normalized) {
+ nextKnownWords.add(normalized);
+ }
+ }
+ }
+ }
+ }
+
+ this.knownWords = nextKnownWords;
+ this.knownWordsLastRefreshedAtMs = Date.now();
+ this.knownWordsScope = this.getKnownWordCacheScope();
+ this.persistKnownWordCacheState();
+ log.info(
+ 'Known-word cache refreshed',
+ `noteCount=${noteIds.length}`,
+ `wordCount=${nextKnownWords.size}`,
+ );
+ } catch (error) {
+ log.warn('Failed to refresh known-word cache:', (error as Error).message);
+ this.deps.showStatusNotification('AnkiConnect: unable to refresh known words');
+ } finally {
+ this.isRefreshingKnownWords = false;
+ }
+ }
+
+ private isKnownWordCacheEnabled(): boolean {
+ return this.deps.getConfig().nPlusOne?.highlightEnabled === true;
+ }
+
+ private getKnownWordRefreshIntervalMs(): number {
+ const minutes = this.deps.getConfig().nPlusOne?.refreshMinutes;
+ const safeMinutes =
+ typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0
+ ? minutes
+ : DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.refreshMinutes;
+ return safeMinutes * 60_000;
+ }
+
+ private getKnownWordDecks(): string[] {
+ const configuredDecks = this.deps.getConfig().nPlusOne?.decks;
+ if (Array.isArray(configuredDecks)) {
+ const decks = configuredDecks
+ .filter((entry): entry is string => typeof entry === 'string')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+ return [...new Set(decks)];
+ }
+
+ const deck = this.deps.getConfig().deck?.trim();
+ return deck ? [deck] : [];
+ }
+
+ private buildKnownWordsQuery(): string {
+ const decks = this.getKnownWordDecks();
+ if (decks.length === 0) {
+ return 'is:note';
+ }
+
+ if (decks.length === 1) {
+ return `deck:"${escapeAnkiSearchValue(decks[0]!)}"`;
+ }
+
+ const deckQueries = decks.map((deck) => `deck:"${escapeAnkiSearchValue(deck)}"`);
+ return `(${deckQueries.join(' OR ')})`;
+ }
+
+ private getKnownWordCacheScope(): string {
+ const decks = this.getKnownWordDecks();
+ if (decks.length === 0) {
+ return 'is:note';
+ }
+ return `decks:${JSON.stringify(decks)}`;
+ }
+
+ private isKnownWordCacheStale(): boolean {
+ if (!this.isKnownWordCacheEnabled()) {
+ return true;
+ }
+ if (this.knownWordsScope !== this.getKnownWordCacheScope()) {
+ return true;
+ }
+ if (this.knownWordsLastRefreshedAtMs <= 0) {
+ return true;
+ }
+ return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
+ }
+
+ private loadKnownWordCacheState(): void {
+ try {
+ if (!fs.existsSync(this.statePath)) {
+ this.knownWords = new Set();
+ this.knownWordsLastRefreshedAtMs = 0;
+ this.knownWordsScope = this.getKnownWordCacheScope();
+ return;
+ }
+
+ const raw = fs.readFileSync(this.statePath, 'utf-8');
+ if (!raw.trim()) {
+ this.knownWords = new Set();
+ this.knownWordsLastRefreshedAtMs = 0;
+ this.knownWordsScope = this.getKnownWordCacheScope();
+ return;
+ }
+
+ const parsed = JSON.parse(raw) as unknown;
+ if (!this.isKnownWordCacheStateValid(parsed)) {
+ this.knownWords = new Set();
+ this.knownWordsLastRefreshedAtMs = 0;
+ this.knownWordsScope = this.getKnownWordCacheScope();
+ return;
+ }
+
+ if (parsed.scope !== this.getKnownWordCacheScope()) {
+ this.knownWords = new Set();
+ this.knownWordsLastRefreshedAtMs = 0;
+ this.knownWordsScope = this.getKnownWordCacheScope();
+ return;
+ }
+
+ const nextKnownWords = new Set();
+ for (const value of parsed.words) {
+ const normalized = this.normalizeKnownWordForLookup(value);
+ if (normalized) {
+ nextKnownWords.add(normalized);
+ }
+ }
+
+ this.knownWords = nextKnownWords;
+ this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
+ this.knownWordsScope = parsed.scope;
+ } catch (error) {
+ log.warn('Failed to load known-word cache state:', (error as Error).message);
+ this.knownWords = new Set();
+ this.knownWordsLastRefreshedAtMs = 0;
+ this.knownWordsScope = this.getKnownWordCacheScope();
+ }
+ }
+
+ private persistKnownWordCacheState(): void {
+ try {
+ const state: KnownWordCacheState = {
+ version: 1,
+ refreshedAtMs: this.knownWordsLastRefreshedAtMs,
+ scope: this.knownWordsScope,
+ words: Array.from(this.knownWords),
+ };
+ fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
+ } catch (error) {
+ log.warn('Failed to persist known-word cache state:', (error as Error).message);
+ }
+ }
+
+ private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
+ if (typeof value !== 'object' || value === null) return false;
+ const candidate = value as Partial;
+ if (candidate.version !== 1) return false;
+ if (typeof candidate.refreshedAtMs !== 'number') return false;
+ if (typeof candidate.scope !== 'string') return false;
+ if (!Array.isArray(candidate.words)) return false;
+ if (!candidate.words.every((entry) => typeof entry === 'string')) {
+ return false;
+ }
+ return true;
+ }
+
+ private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
+ const words: string[] = [];
+ const preferredFields = ['Expression', 'Word'];
+ for (const preferredField of preferredFields) {
+ const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
+ if (!fieldName) continue;
+
+ const raw = noteInfo.fields[fieldName]?.value;
+ if (!raw) continue;
+
+ const extracted = this.normalizeRawKnownWordValue(raw);
+ if (extracted) {
+ words.push(extracted);
+ }
+ }
+ return words;
+ }
+
+ private normalizeRawKnownWordValue(value: string): string {
+ return value
+ .replace(/<[^>]*>/g, '')
+ .replace(/\u3000/g, ' ')
+ .trim();
+ }
+
+ private normalizeKnownWordForLookup(value: string): string {
+ return this.normalizeRawKnownWordValue(value).toLowerCase();
+ }
+}
+
+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 escapeAnkiSearchValue(value: string): string {
+ return value
+ .replace(/\\/g, '\\\\')
+ .replace(/\"/g, '\\"')
+ .replace(/([:*?()\[\]{}])/g, '\\$1');
+}
diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts
new file mode 100644
index 0000000..c953ef5
--- /dev/null
+++ b/src/anki-integration/note-update-workflow.test.ts
@@ -0,0 +1,173 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ NoteUpdateWorkflow,
+ type NoteUpdateWorkflowDeps,
+ type NoteUpdateWorkflowNoteInfo,
+} from './note-update-workflow';
+
+function createWorkflowHarness() {
+ const updates: Array<{ noteId: number; fields: Record }> = [];
+ const notifications: Array<{ noteId: number; label: string | number }> = [];
+ const warnings: string[] = [];
+
+ const deps: NoteUpdateWorkflowDeps = {
+ client: {
+ notesInfo: async (_noteIds: number[]) =>
+ [
+ {
+ noteId: 42,
+ fields: {
+ Expression: { value: 'taberu' },
+ Sentence: { value: '' },
+ },
+ },
+ ] satisfies NoteUpdateWorkflowNoteInfo[],
+ updateNoteFields: async (noteId: number, fields: Record) => {
+ updates.push({ noteId, fields });
+ },
+ storeMediaFile: async () => undefined,
+ },
+ getConfig: () => ({
+ fields: {
+ sentence: 'Sentence',
+ },
+ media: {},
+ behavior: {},
+ }),
+ getCurrentSubtitleText: () => 'subtitle-text',
+ getCurrentSubtitleStart: () => 12.3,
+ getEffectiveSentenceCardConfig: () => ({
+ sentenceField: 'Sentence',
+ kikuEnabled: false,
+ kikuFieldGrouping: 'disabled' as const,
+ }),
+ appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
+ extractFields: (fields: Record) => {
+ const out: Record = {};
+ for (const [key, value] of Object.entries(fields)) {
+ out[key.toLowerCase()] = value.value;
+ }
+ return out;
+ },
+ findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
+ handleFieldGroupingAuto: async (
+ _originalNoteId,
+ _newNoteId,
+ _newNoteInfo,
+ _expression,
+ ) => undefined,
+ handleFieldGroupingManual: async (
+ _originalNoteId,
+ _newNoteId,
+ _newNoteInfo,
+ _expression,
+ ) => false,
+ processSentence: (text: string, _noteFields: Record) => text,
+ resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
+ if (!preferred) return null;
+ const names = Object.keys(noteInfo.fields);
+ return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
+ },
+ getResolvedSentenceAudioFieldName: () => null,
+ mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
+ generateAudioFilename: () => 'audio_1.mp3',
+ generateAudio: async () => null,
+ generateImageFilename: () => 'image_1.jpg',
+ generateImage: async () => null,
+ formatMiscInfoPattern: () => '',
+ addConfiguredTagsToNote: async () => undefined,
+ showNotification: async (noteId: number, label: string | number) => {
+ notifications.push({ noteId, label });
+ },
+ showOsdNotification: (_text: string) => undefined,
+ beginUpdateProgress: (_text: string) => undefined,
+ endUpdateProgress: () => undefined,
+ logWarn: (message: string, ..._args: unknown[]) => warnings.push(message),
+ logInfo: (_message: string) => undefined,
+ logError: (_message: string) => undefined,
+ };
+
+ return {
+ workflow: new NoteUpdateWorkflow(deps),
+ updates,
+ notifications,
+ warnings,
+ deps,
+ };
+}
+
+test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
+ const harness = createWorkflowHarness();
+
+ await harness.workflow.execute(42);
+
+ assert.equal(harness.updates.length, 1);
+ assert.equal(harness.updates[0]?.noteId, 42);
+ assert.equal(harness.updates[0]?.fields.Sentence, 'subtitle-text');
+ assert.equal(harness.notifications.length, 1);
+});
+
+test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
+ const harness = createWorkflowHarness();
+ harness.deps.client.notesInfo = async () => [];
+
+ await harness.workflow.execute(777);
+
+ assert.equal(harness.updates.length, 0);
+ assert.equal(harness.notifications.length, 0);
+ assert.equal(harness.warnings.length, 1);
+});
+
+test('NoteUpdateWorkflow updates note before auto field grouping merge', async () => {
+ const harness = createWorkflowHarness();
+ const callOrder: string[] = [];
+ let notesInfoCallCount = 0;
+ harness.deps.getEffectiveSentenceCardConfig = () => ({
+ sentenceField: 'Sentence',
+ kikuEnabled: true,
+ kikuFieldGrouping: 'auto',
+ });
+ harness.deps.findDuplicateNote = async () => 99;
+ harness.deps.client.notesInfo = async () => {
+ notesInfoCallCount += 1;
+ if (notesInfoCallCount === 1) {
+ return [
+ {
+ noteId: 42,
+ fields: {
+ Expression: { value: 'taberu' },
+ Sentence: { value: '' },
+ },
+ },
+ ] satisfies NoteUpdateWorkflowNoteInfo[];
+ }
+ return [
+ {
+ noteId: 42,
+ fields: {
+ Expression: { value: 'taberu' },
+ Sentence: { value: 'subtitle-text' },
+ },
+ },
+ ] satisfies NoteUpdateWorkflowNoteInfo[];
+ };
+ harness.deps.client.updateNoteFields = async (noteId, fields) => {
+ callOrder.push('update');
+ harness.updates.push({ noteId, fields });
+ };
+ harness.deps.handleFieldGroupingAuto = async (
+ _originalNoteId,
+ _newNoteId,
+ newNoteInfo,
+ _expression,
+ ) => {
+ callOrder.push('auto');
+ assert.equal(newNoteInfo.fields.Sentence?.value, 'subtitle-text');
+ };
+
+ await harness.workflow.execute(42);
+
+ assert.deepEqual(callOrder, ['update', 'auto']);
+ assert.equal(harness.updates.length, 1);
+});
diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts
new file mode 100644
index 0000000..2ffc761
--- /dev/null
+++ b/src/anki-integration/note-update-workflow.ts
@@ -0,0 +1,242 @@
+import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
+
+export interface NoteUpdateWorkflowNoteInfo {
+ noteId: number;
+ fields: Record;
+}
+
+export interface NoteUpdateWorkflowDeps {
+ client: {
+ notesInfo(noteIds: number[]): Promise;
+ updateNoteFields(noteId: number, fields: Record): Promise;
+ storeMediaFile(filename: string, data: Buffer): Promise;
+ };
+ getConfig: () => {
+ fields?: {
+ sentence?: string;
+ image?: string;
+ miscInfo?: string;
+ };
+ media?: {
+ generateAudio?: boolean;
+ generateImage?: boolean;
+ };
+ behavior?: {
+ overwriteAudio?: boolean;
+ overwriteImage?: boolean;
+ };
+ };
+ getCurrentSubtitleText: () => string | undefined;
+ getCurrentSubtitleStart: () => number | undefined;
+ getEffectiveSentenceCardConfig: () => {
+ sentenceField: string;
+ kikuEnabled: boolean;
+ kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
+ };
+ appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
+ extractFields: (fields: Record) => Record;
+ findDuplicateNote: (
+ expression: string,
+ excludeNoteId: number,
+ noteInfo: NoteUpdateWorkflowNoteInfo,
+ ) => Promise;
+ handleFieldGroupingAuto: (
+ originalNoteId: number,
+ newNoteId: number,
+ newNoteInfo: NoteUpdateWorkflowNoteInfo,
+ expression: string,
+ ) => Promise;
+ handleFieldGroupingManual: (
+ originalNoteId: number,
+ newNoteId: number,
+ newNoteInfo: NoteUpdateWorkflowNoteInfo,
+ expression: string,
+ ) => Promise;
+ processSentence: (mpvSentence: string, noteFields: Record) => string;
+ resolveConfiguredFieldName: (
+ noteInfo: NoteUpdateWorkflowNoteInfo,
+ ...preferredNames: (string | undefined)[]
+ ) => string | null;
+ getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
+ mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
+ generateAudioFilename: () => string;
+ generateAudio: () => Promise;
+ generateImageFilename: () => string;
+ generateImage: () => Promise;
+ formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
+ addConfiguredTagsToNote: (noteId: number) => Promise;
+ showNotification: (noteId: number, label: string | number) => Promise;
+ showOsdNotification: (message: string) => void;
+ beginUpdateProgress: (initialMessage: string) => void;
+ endUpdateProgress: () => void;
+ logWarn: (message: string, ...args: unknown[]) => void;
+ logInfo: (message: string, ...args: unknown[]) => void;
+ logError: (message: string, ...args: unknown[]) => void;
+}
+
+export class NoteUpdateWorkflow {
+ constructor(private readonly deps: NoteUpdateWorkflowDeps) {}
+
+ async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise {
+ this.deps.beginUpdateProgress('Updating card');
+ try {
+ const notesInfoResult = await this.deps.client.notesInfo([noteId]);
+ const notesInfo = notesInfoResult as NoteUpdateWorkflowNoteInfo[];
+ if (!notesInfo || notesInfo.length === 0) {
+ this.deps.logWarn('Card not found:', noteId);
+ return;
+ }
+
+ const noteInfo = notesInfo[0]!;
+ this.deps.appendKnownWordsFromNoteInfo(noteInfo);
+ const fields = this.deps.extractFields(noteInfo.fields);
+
+ const expressionText = fields.expression || fields.word || '';
+ if (!expressionText) {
+ this.deps.logWarn('No expression/word field found in card:', noteId);
+ return;
+ }
+
+ const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
+ const shouldRunFieldGrouping =
+ !options?.skipKikuFieldGrouping &&
+ sentenceCardConfig.kikuEnabled &&
+ sentenceCardConfig.kikuFieldGrouping !== 'disabled';
+ let duplicateNoteId: number | null = null;
+ if (shouldRunFieldGrouping) {
+ duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
+ }
+
+ const updatedFields: Record = {};
+ let updatePerformed = false;
+ let miscInfoFilename: string | null = null;
+ const sentenceField = sentenceCardConfig.sentenceField;
+
+ const currentSubtitleText = this.deps.getCurrentSubtitleText();
+ if (sentenceField && currentSubtitleText) {
+ const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
+ updatedFields[sentenceField] = processedSentence;
+ updatePerformed = true;
+ }
+
+ const config = this.deps.getConfig();
+
+ if (config.media?.generateAudio) {
+ try {
+ const audioFilename = this.deps.generateAudioFilename();
+ const audioBuffer = await this.deps.generateAudio();
+
+ if (audioBuffer) {
+ await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
+ const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo);
+ if (sentenceAudioField) {
+ const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
+ updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
+ existingAudio,
+ `[sound:${audioFilename}]`,
+ config.behavior?.overwriteAudio !== false,
+ );
+ }
+ miscInfoFilename = audioFilename;
+ updatePerformed = true;
+ }
+ } catch (error) {
+ this.deps.logError('Failed to generate audio:', (error as Error).message);
+ this.deps.showOsdNotification(`Audio generation failed: ${(error as Error).message}`);
+ }
+ }
+
+ if (config.media?.generateImage) {
+ try {
+ const imageFilename = this.deps.generateImageFilename();
+ const imageBuffer = await this.deps.generateImage();
+
+ if (imageBuffer) {
+ await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
+ const imageFieldName = this.deps.resolveConfiguredFieldName(
+ noteInfo,
+ config.fields?.image,
+ DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
+ );
+ if (!imageFieldName) {
+ this.deps.logWarn('Image field not found on note, skipping image update');
+ } else {
+ const existingImage = noteInfo.fields[imageFieldName]?.value || '';
+ updatedFields[imageFieldName] = this.deps.mergeFieldValue(
+ existingImage,
+ `
`,
+ config.behavior?.overwriteImage !== false,
+ );
+ miscInfoFilename = imageFilename;
+ updatePerformed = true;
+ }
+ }
+ } catch (error) {
+ this.deps.logError('Failed to generate image:', (error as Error).message);
+ this.deps.showOsdNotification(`Image generation failed: ${(error as Error).message}`);
+ }
+ }
+
+ if (config.fields?.miscInfo) {
+ const miscInfo = this.deps.formatMiscInfoPattern(
+ miscInfoFilename || '',
+ this.deps.getCurrentSubtitleStart(),
+ );
+ const miscInfoField = this.deps.resolveConfiguredFieldName(
+ noteInfo,
+ config.fields?.miscInfo,
+ );
+ if (miscInfo && miscInfoField) {
+ updatedFields[miscInfoField] = miscInfo;
+ updatePerformed = true;
+ }
+ }
+
+ if (updatePerformed) {
+ await this.deps.client.updateNoteFields(noteId, updatedFields);
+ await this.deps.addConfiguredTagsToNote(noteId);
+ this.deps.logInfo('Updated card fields for:', expressionText);
+ await this.deps.showNotification(noteId, expressionText);
+ }
+
+ if (shouldRunFieldGrouping && duplicateNoteId !== null) {
+ let noteInfoForGrouping = noteInfo;
+ if (updatePerformed) {
+ const refreshedInfoResult = await this.deps.client.notesInfo([noteId]);
+ const refreshedInfo = refreshedInfoResult as NoteUpdateWorkflowNoteInfo[];
+ if (!refreshedInfo || refreshedInfo.length === 0) {
+ this.deps.logWarn('Card not found after update:', noteId);
+ return;
+ }
+ noteInfoForGrouping = refreshedInfo[0]!;
+ }
+
+ if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
+ await this.deps.handleFieldGroupingAuto(
+ duplicateNoteId,
+ noteId,
+ noteInfoForGrouping,
+ expressionText,
+ );
+ return;
+ }
+ if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
+ await this.deps.handleFieldGroupingManual(
+ duplicateNoteId,
+ noteId,
+ noteInfoForGrouping,
+ expressionText,
+ );
+ }
+ }
+ } catch (error) {
+ if ((error as Error).message.includes('note was not found')) {
+ this.deps.logWarn('Card was deleted before update:', noteId);
+ } else {
+ this.deps.logError('Error processing new card:', (error as Error).message);
+ }
+ } finally {
+ this.deps.endUpdateProgress();
+ }
+ }
+}
diff --git a/src/anki-integration/polling.ts b/src/anki-integration/polling.ts
new file mode 100644
index 0000000..372b40a
--- /dev/null
+++ b/src/anki-integration/polling.ts
@@ -0,0 +1,119 @@
+export interface PollingRunnerDeps {
+ getDeck: () => string | undefined;
+ getPollingRate: () => number;
+ findNotes: (
+ query: string,
+ options?: {
+ maxRetries?: number;
+ },
+ ) => Promise;
+ shouldAutoUpdateNewCards: () => boolean;
+ processNewCard: (noteId: number) => Promise;
+ isUpdateInProgress: () => boolean;
+ setUpdateInProgress: (value: boolean) => void;
+ getTrackedNoteIds: () => Set;
+ setTrackedNoteIds: (noteIds: Set) => void;
+ showStatusNotification: (message: string) => void;
+ logDebug: (...args: unknown[]) => void;
+ logInfo: (...args: unknown[]) => void;
+ logWarn: (...args: unknown[]) => void;
+}
+
+export class PollingRunner {
+ private pollingInterval: ReturnType | null = null;
+ private initialized = false;
+ private backoffMs = 200;
+ private maxBackoffMs = 5000;
+ private nextPollTime = 0;
+
+ constructor(private readonly deps: PollingRunnerDeps) {}
+
+ get isRunning(): boolean {
+ return this.pollingInterval !== null;
+ }
+
+ start(): void {
+ if (this.pollingInterval) {
+ this.stop();
+ }
+
+ void this.pollOnce();
+ this.pollingInterval = setInterval(() => {
+ void this.pollOnce();
+ }, this.deps.getPollingRate());
+ }
+
+ stop(): void {
+ if (this.pollingInterval) {
+ clearInterval(this.pollingInterval);
+ this.pollingInterval = null;
+ }
+ }
+
+ async pollOnce(): Promise {
+ if (this.deps.isUpdateInProgress()) return;
+ if (Date.now() < this.nextPollTime) return;
+
+ this.deps.setUpdateInProgress(true);
+ try {
+ const query = this.deps.getDeck() ? `"deck:${this.deps.getDeck()}" added:1` : 'added:1';
+ const noteIds = await this.deps.findNotes(query, {
+ maxRetries: 0,
+ });
+ const currentNoteIds = new Set(noteIds);
+
+ const previousNoteIds = this.deps.getTrackedNoteIds();
+ if (!this.initialized) {
+ this.deps.setTrackedNoteIds(currentNoteIds);
+ this.initialized = true;
+ this.deps.logInfo(`AnkiConnect initialized with ${currentNoteIds.size} existing cards`);
+ this.backoffMs = 200;
+ return;
+ }
+
+ const newNoteIds = Array.from(currentNoteIds).filter((id) => !previousNoteIds.has(id));
+
+ if (newNoteIds.length > 0) {
+ this.deps.logInfo('Found new cards:', newNoteIds);
+
+ for (const noteId of newNoteIds) {
+ previousNoteIds.add(noteId);
+ }
+ this.deps.setTrackedNoteIds(previousNoteIds);
+
+ if (this.deps.shouldAutoUpdateNewCards()) {
+ for (const noteId of newNoteIds) {
+ await this.deps.processNewCard(noteId);
+ }
+ } else {
+ this.deps.logInfo(
+ 'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.',
+ );
+ }
+ }
+
+ if (this.backoffMs > 200) {
+ this.deps.logInfo('AnkiConnect connection restored');
+ }
+ this.backoffMs = 200;
+ } catch (error) {
+ const wasBackingOff = this.backoffMs > 200;
+ this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs);
+ this.nextPollTime = Date.now() + this.backoffMs;
+ if (!wasBackingOff) {
+ this.deps.logWarn('AnkiConnect polling failed, backing off...');
+ this.deps.showStatusNotification('AnkiConnect: unable to connect');
+ }
+ this.deps.logWarn((error as Error).message);
+ } finally {
+ this.deps.setUpdateInProgress(false);
+ }
+ }
+
+ async poll(): Promise {
+ if (this.pollingInterval) {
+ return;
+ }
+ return this.pollOnce();
+ }
+}
diff --git a/src/anki-integration/ui-feedback.ts b/src/anki-integration/ui-feedback.ts
new file mode 100644
index 0000000..09844d7
--- /dev/null
+++ b/src/anki-integration/ui-feedback.ts
@@ -0,0 +1,104 @@
+import { NotificationOptions } from '../types';
+
+export interface UiFeedbackState {
+ progressDepth: number;
+ progressTimer: ReturnType | null;
+ progressMessage: string;
+ progressFrame: number;
+}
+
+export interface UiFeedbackNotificationContext {
+ getNotificationType: () => string | undefined;
+ showOsd: (text: string) => void;
+ showSystemNotification: (title: string, options: NotificationOptions) => void;
+}
+
+export interface UiFeedbackOptions {
+ setUpdateInProgress: (value: boolean) => void;
+ showOsdNotification: (text: string) => void;
+}
+
+export function createUiFeedbackState(): UiFeedbackState {
+ return {
+ progressDepth: 0,
+ progressTimer: null,
+ progressMessage: '',
+ progressFrame: 0,
+ };
+}
+
+export function showStatusNotification(
+ message: string,
+ context: UiFeedbackNotificationContext,
+): void {
+ const type = context.getNotificationType() || 'osd';
+
+ if (type === 'osd' || type === 'both') {
+ context.showOsd(message);
+ }
+
+ if (type === 'system' || type === 'both') {
+ context.showSystemNotification('SubMiner', { body: message });
+ }
+}
+
+export function beginUpdateProgress(
+ state: UiFeedbackState,
+ initialMessage: string,
+ showProgressTick: (text: string) => void,
+): void {
+ state.progressDepth += 1;
+ if (state.progressDepth > 1) return;
+
+ state.progressMessage = initialMessage;
+ state.progressFrame = 0;
+ showProgressTick(`${state.progressMessage}`);
+ state.progressTimer = setInterval(() => {
+ showProgressTick(`${state.progressMessage} ${['|', '/', '-', '\\'][state.progressFrame % 4]}`);
+ state.progressFrame += 1;
+ }, 180);
+}
+
+export function endUpdateProgress(
+ state: UiFeedbackState,
+ clearProgressTimer: (timer: ReturnType) => void,
+): void {
+ state.progressDepth = Math.max(0, state.progressDepth - 1);
+ if (state.progressDepth > 0) return;
+
+ if (state.progressTimer) {
+ clearProgressTimer(state.progressTimer);
+ state.progressTimer = null;
+ }
+ state.progressMessage = '';
+ state.progressFrame = 0;
+}
+
+export function showProgressTick(
+ state: UiFeedbackState,
+ showOsdNotification: (text: string) => void,
+): void {
+ if (!state.progressMessage) return;
+ const frames = ['|', '/', '-', '\\'];
+ const frame = frames[state.progressFrame % frames.length];
+ state.progressFrame += 1;
+ showOsdNotification(`${state.progressMessage} ${frame}`);
+}
+
+export async function withUpdateProgress(
+ state: UiFeedbackState,
+ options: UiFeedbackOptions,
+ initialMessage: string,
+ action: () => Promise,
+): Promise {
+ beginUpdateProgress(state, initialMessage, () =>
+ showProgressTick(state, options.showOsdNotification),
+ );
+ options.setUpdateInProgress(true);
+ try {
+ return await action();
+ } finally {
+ options.setUpdateInProgress(false);
+ endUpdateProgress(state, clearInterval);
+ }
+}
diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts
new file mode 100644
index 0000000..1d2e76b
--- /dev/null
+++ b/src/cli/args.test.ts
@@ -0,0 +1,103 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { hasExplicitCommand, parseArgs, shouldStartApp } from './args';
+
+test('parseArgs parses booleans and value flags', () => {
+ const args = parseArgs([
+ '--background',
+ '--start',
+ '--socket',
+ '/tmp/mpv.sock',
+ '--backend=hyprland',
+ '--port',
+ '6000',
+ '--log-level',
+ 'warn',
+ '--debug',
+ '--jellyfin-play',
+ '--jellyfin-server',
+ 'http://jellyfin.local:8096',
+ '--jellyfin-item-id',
+ 'item-123',
+ '--jellyfin-audio-stream-index',
+ '2',
+ ]);
+
+ assert.equal(args.background, true);
+ assert.equal(args.start, true);
+ assert.equal(args.socketPath, '/tmp/mpv.sock');
+ assert.equal(args.backend, 'hyprland');
+ assert.equal(args.texthookerPort, 6000);
+ assert.equal(args.logLevel, 'warn');
+ assert.equal(args.debug, true);
+ assert.equal(args.jellyfinPlay, true);
+ assert.equal(args.jellyfinServer, 'http://jellyfin.local:8096');
+ assert.equal(args.jellyfinItemId, 'item-123');
+ assert.equal(args.jellyfinAudioStreamIndex, 2);
+});
+
+test('parseArgs ignores missing value after --log-level', () => {
+ const args = parseArgs(['--log-level', '--start']);
+ assert.equal(args.logLevel, undefined);
+ assert.equal(args.start, true);
+});
+
+test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
+ const stopOnly = parseArgs(['--stop']);
+ assert.equal(hasExplicitCommand(stopOnly), true);
+ assert.equal(shouldStartApp(stopOnly), false);
+
+ const toggle = parseArgs(['--toggle-visible-overlay']);
+ assert.equal(hasExplicitCommand(toggle), true);
+ assert.equal(shouldStartApp(toggle), true);
+
+ const noCommand = parseArgs(['--log-level', 'warn']);
+ assert.equal(hasExplicitCommand(noCommand), false);
+ assert.equal(shouldStartApp(noCommand), false);
+
+ const refreshKnownWords = parseArgs(['--refresh-known-words']);
+ assert.equal(refreshKnownWords.help, false);
+ assert.equal(hasExplicitCommand(refreshKnownWords), true);
+ assert.equal(shouldStartApp(refreshKnownWords), false);
+
+ const anilistStatus = parseArgs(['--anilist-status']);
+ assert.equal(anilistStatus.anilistStatus, true);
+ assert.equal(hasExplicitCommand(anilistStatus), true);
+ assert.equal(shouldStartApp(anilistStatus), false);
+
+ const anilistRetryQueue = parseArgs(['--anilist-retry-queue']);
+ assert.equal(anilistRetryQueue.anilistRetryQueue, true);
+ assert.equal(hasExplicitCommand(anilistRetryQueue), true);
+ assert.equal(shouldStartApp(anilistRetryQueue), false);
+
+ const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
+ assert.equal(jellyfinLibraries.jellyfinLibraries, true);
+ assert.equal(hasExplicitCommand(jellyfinLibraries), true);
+ assert.equal(shouldStartApp(jellyfinLibraries), false);
+
+ const jellyfinSetup = parseArgs(['--jellyfin']);
+ assert.equal(jellyfinSetup.jellyfin, true);
+ assert.equal(hasExplicitCommand(jellyfinSetup), true);
+ assert.equal(shouldStartApp(jellyfinSetup), true);
+
+ const jellyfinPlay = parseArgs(['--jellyfin-play']);
+ assert.equal(jellyfinPlay.jellyfinPlay, true);
+ assert.equal(hasExplicitCommand(jellyfinPlay), true);
+ assert.equal(shouldStartApp(jellyfinPlay), true);
+
+ const jellyfinSubtitles = parseArgs(['--jellyfin-subtitles', '--jellyfin-subtitle-urls']);
+ assert.equal(jellyfinSubtitles.jellyfinSubtitles, true);
+ assert.equal(jellyfinSubtitles.jellyfinSubtitleUrlsOnly, true);
+ assert.equal(hasExplicitCommand(jellyfinSubtitles), true);
+ assert.equal(shouldStartApp(jellyfinSubtitles), false);
+
+ const jellyfinRemoteAnnounce = parseArgs(['--jellyfin-remote-announce']);
+ assert.equal(jellyfinRemoteAnnounce.jellyfinRemoteAnnounce, true);
+ assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
+ assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
+
+ const background = parseArgs(['--background']);
+ assert.equal(background.background, true);
+ assert.equal(hasExplicitCommand(background), true);
+ assert.equal(shouldStartApp(background), true);
+});
diff --git a/src/cli/args.ts b/src/cli/args.ts
new file mode 100644
index 0000000..a031d54
--- /dev/null
+++ b/src/cli/args.ts
@@ -0,0 +1,352 @@
+export interface CliArgs {
+ background: boolean;
+ start: boolean;
+ stop: boolean;
+ toggle: boolean;
+ toggleVisibleOverlay: boolean;
+ toggleInvisibleOverlay: boolean;
+ settings: boolean;
+ show: boolean;
+ hide: boolean;
+ showVisibleOverlay: boolean;
+ hideVisibleOverlay: boolean;
+ showInvisibleOverlay: boolean;
+ hideInvisibleOverlay: boolean;
+ copySubtitle: boolean;
+ copySubtitleMultiple: boolean;
+ mineSentence: boolean;
+ mineSentenceMultiple: boolean;
+ updateLastCardFromClipboard: boolean;
+ refreshKnownWords: boolean;
+ toggleSecondarySub: boolean;
+ triggerFieldGrouping: boolean;
+ triggerSubsync: boolean;
+ markAudioCard: boolean;
+ openRuntimeOptions: boolean;
+ anilistStatus: boolean;
+ anilistLogout: boolean;
+ anilistSetup: boolean;
+ anilistRetryQueue: boolean;
+ jellyfin: boolean;
+ jellyfinLogin: boolean;
+ jellyfinLogout: boolean;
+ jellyfinLibraries: boolean;
+ jellyfinItems: boolean;
+ jellyfinSubtitles: boolean;
+ jellyfinSubtitleUrlsOnly: boolean;
+ jellyfinPlay: boolean;
+ jellyfinRemoteAnnounce: boolean;
+ texthooker: boolean;
+ help: boolean;
+ autoStartOverlay: boolean;
+ generateConfig: boolean;
+ configPath?: string;
+ backupOverwrite: boolean;
+ socketPath?: string;
+ backend?: string;
+ texthookerPort?: number;
+ jellyfinServer?: string;
+ jellyfinUsername?: string;
+ jellyfinPassword?: string;
+ jellyfinLibraryId?: string;
+ jellyfinItemId?: string;
+ jellyfinSearch?: string;
+ jellyfinLimit?: number;
+ jellyfinAudioStreamIndex?: number;
+ jellyfinSubtitleStreamIndex?: number;
+ debug: boolean;
+ logLevel?: 'debug' | 'info' | 'warn' | 'error';
+}
+
+export type CliCommandSource = 'initial' | 'second-instance';
+
+export function parseArgs(argv: string[]): CliArgs {
+ const args: CliArgs = {
+ background: false,
+ start: false,
+ stop: false,
+ toggle: false,
+ toggleVisibleOverlay: false,
+ toggleInvisibleOverlay: false,
+ settings: false,
+ show: false,
+ hide: false,
+ showVisibleOverlay: false,
+ hideVisibleOverlay: false,
+ showInvisibleOverlay: false,
+ hideInvisibleOverlay: false,
+ copySubtitle: false,
+ copySubtitleMultiple: false,
+ mineSentence: false,
+ mineSentenceMultiple: false,
+ updateLastCardFromClipboard: false,
+ refreshKnownWords: false,
+ toggleSecondarySub: false,
+ triggerFieldGrouping: false,
+ triggerSubsync: false,
+ markAudioCard: false,
+ openRuntimeOptions: false,
+ anilistStatus: false,
+ anilistLogout: false,
+ anilistSetup: false,
+ anilistRetryQueue: false,
+ jellyfin: false,
+ jellyfinLogin: false,
+ jellyfinLogout: false,
+ jellyfinLibraries: false,
+ jellyfinItems: false,
+ jellyfinSubtitles: false,
+ jellyfinSubtitleUrlsOnly: false,
+ jellyfinPlay: false,
+ jellyfinRemoteAnnounce: false,
+ texthooker: false,
+ help: false,
+ autoStartOverlay: false,
+ generateConfig: false,
+ backupOverwrite: false,
+ debug: false,
+ };
+
+ const readValue = (value?: string): string | undefined => {
+ if (!value) return undefined;
+ if (value.startsWith('--')) return undefined;
+ return value;
+ };
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (!arg || !arg.startsWith('--')) continue;
+
+ if (arg === '--background') args.background = true;
+ else if (arg === '--start') args.start = true;
+ else if (arg === '--stop') args.stop = true;
+ else if (arg === '--toggle') args.toggle = true;
+ else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
+ else if (arg === '--toggle-invisible-overlay') args.toggleInvisibleOverlay = true;
+ else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
+ else if (arg === '--show') args.show = true;
+ else if (arg === '--hide') args.hide = true;
+ else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true;
+ else if (arg === '--hide-visible-overlay') args.hideVisibleOverlay = true;
+ else if (arg === '--show-invisible-overlay') args.showInvisibleOverlay = true;
+ else if (arg === '--hide-invisible-overlay') args.hideInvisibleOverlay = true;
+ else if (arg === '--copy-subtitle') args.copySubtitle = true;
+ else if (arg === '--copy-subtitle-multiple') args.copySubtitleMultiple = true;
+ else if (arg === '--mine-sentence') args.mineSentence = true;
+ else if (arg === '--mine-sentence-multiple') args.mineSentenceMultiple = true;
+ else if (arg === '--update-last-card-from-clipboard') args.updateLastCardFromClipboard = true;
+ else if (arg === '--refresh-known-words') args.refreshKnownWords = true;
+ else if (arg === '--toggle-secondary-sub') args.toggleSecondarySub = true;
+ else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true;
+ else if (arg === '--trigger-subsync') args.triggerSubsync = true;
+ else if (arg === '--mark-audio-card') args.markAudioCard = true;
+ else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
+ else if (arg === '--anilist-status') args.anilistStatus = true;
+ else if (arg === '--anilist-logout') args.anilistLogout = true;
+ else if (arg === '--anilist-setup') args.anilistSetup = true;
+ else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
+ else if (arg === '--jellyfin') args.jellyfin = true;
+ else if (arg === '--jellyfin-login') args.jellyfinLogin = true;
+ else if (arg === '--jellyfin-logout') args.jellyfinLogout = true;
+ else if (arg === '--jellyfin-libraries') args.jellyfinLibraries = true;
+ else if (arg === '--jellyfin-items') args.jellyfinItems = true;
+ else if (arg === '--jellyfin-subtitles') args.jellyfinSubtitles = true;
+ else if (arg === '--jellyfin-subtitle-urls') {
+ args.jellyfinSubtitles = true;
+ args.jellyfinSubtitleUrlsOnly = true;
+ } else if (arg === '--jellyfin-play') args.jellyfinPlay = true;
+ else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
+ else if (arg === '--texthooker') args.texthooker = true;
+ else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
+ else if (arg === '--generate-config') args.generateConfig = true;
+ else if (arg === '--backup-overwrite') args.backupOverwrite = true;
+ else if (arg === '--help') args.help = true;
+ else if (arg === '--debug') args.debug = true;
+ else if (arg.startsWith('--log-level=')) {
+ const value = arg.split('=', 2)[1]?.toLowerCase();
+ if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') {
+ args.logLevel = value;
+ }
+ } else if (arg === '--log-level') {
+ const value = readValue(argv[i + 1])?.toLowerCase();
+ if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') {
+ args.logLevel = value;
+ }
+ } else if (arg.startsWith('--config-path=')) {
+ const value = arg.split('=', 2)[1];
+ if (value) args.configPath = value;
+ } else if (arg === '--config-path') {
+ const value = readValue(argv[i + 1]);
+ if (value) args.configPath = value;
+ } else if (arg.startsWith('--socket=')) {
+ const value = arg.split('=', 2)[1];
+ if (value) args.socketPath = value;
+ } else if (arg === '--socket') {
+ const value = readValue(argv[i + 1]);
+ if (value) args.socketPath = value;
+ } else if (arg.startsWith('--backend=')) {
+ const value = arg.split('=', 2)[1];
+ if (value) args.backend = value;
+ } else if (arg === '--backend') {
+ const value = readValue(argv[i + 1]);
+ if (value) args.backend = value;
+ } else if (arg.startsWith('--port=')) {
+ const value = Number(arg.split('=', 2)[1]);
+ if (!Number.isNaN(value)) args.texthookerPort = value;
+ } else if (arg === '--port') {
+ const value = Number(readValue(argv[i + 1]));
+ if (!Number.isNaN(value)) args.texthookerPort = value;
+ } else if (arg.startsWith('--jellyfin-server=')) {
+ const value = arg.split('=', 2)[1];
+ if (value) args.jellyfinServer = value;
+ } else if (arg === '--jellyfin-server') {
+ const value = readValue(argv[i + 1]);
+ if (value) args.jellyfinServer = value;
+ } else if (arg.startsWith('--jellyfin-username=')) {
+ const value = arg.split('=', 2)[1];
+ if (value) args.jellyfinUsername = value;
+ } else if (arg === '--jellyfin-username') {
+ const value = readValue(argv[i + 1]);
+ if (value) args.jellyfinUsername = value;
+ } else if (arg.startsWith('--jellyfin-password=')) {
+ const value = arg.split('=', 2)[1];
+ if (value) args.jellyfinPassword = value;
+ } else if (arg === '--jellyfin-password') {
+ const value = readValue(argv[i + 1]);
+ if (value) args.jellyfinPassword = value;
+ } else if (arg.startsWith('--jellyfin-library-id=')) {
+ const value = arg.split('=', 2)[1];
+ if (value) args.jellyfinLibraryId = value;
+ } else if (arg === '--jellyfin-library-id') {
+ const value = readValue(argv[i + 1]);
+ if (value) args.jellyfinLibraryId = value;
+ } else if (arg.startsWith('--jellyfin-item-id=')) {
+ const value = arg.split('=', 2)[1];
+ if (value) args.jellyfinItemId = value;
+ } else if (arg === '--jellyfin-item-id') {
+ const value = readValue(argv[i + 1]);
+ if (value) args.jellyfinItemId = value;
+ } else if (arg.startsWith('--jellyfin-search=')) {
+ const value = arg.split('=', 2)[1];
+ if (value) args.jellyfinSearch = value;
+ } else if (arg === '--jellyfin-search') {
+ const value = readValue(argv[i + 1]);
+ if (value) args.jellyfinSearch = value;
+ } else if (arg.startsWith('--jellyfin-limit=')) {
+ const value = Number(arg.split('=', 2)[1]);
+ if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value);
+ } else if (arg === '--jellyfin-limit') {
+ const value = Number(readValue(argv[i + 1]));
+ if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value);
+ } else if (arg.startsWith('--jellyfin-audio-stream-index=')) {
+ const value = Number(arg.split('=', 2)[1]);
+ if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value;
+ } else if (arg === '--jellyfin-audio-stream-index') {
+ const value = Number(readValue(argv[i + 1]));
+ if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value;
+ } else if (arg.startsWith('--jellyfin-subtitle-stream-index=')) {
+ const value = Number(arg.split('=', 2)[1]);
+ if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value;
+ } else if (arg === '--jellyfin-subtitle-stream-index') {
+ const value = Number(readValue(argv[i + 1]));
+ if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value;
+ }
+ }
+
+ return args;
+}
+
+export function hasExplicitCommand(args: CliArgs): boolean {
+ return (
+ args.background ||
+ args.start ||
+ args.stop ||
+ args.toggle ||
+ args.toggleVisibleOverlay ||
+ args.toggleInvisibleOverlay ||
+ args.settings ||
+ args.show ||
+ args.hide ||
+ args.showVisibleOverlay ||
+ args.hideVisibleOverlay ||
+ args.showInvisibleOverlay ||
+ args.hideInvisibleOverlay ||
+ args.copySubtitle ||
+ args.copySubtitleMultiple ||
+ args.mineSentence ||
+ args.mineSentenceMultiple ||
+ args.updateLastCardFromClipboard ||
+ args.refreshKnownWords ||
+ args.toggleSecondarySub ||
+ args.triggerFieldGrouping ||
+ args.triggerSubsync ||
+ args.markAudioCard ||
+ args.openRuntimeOptions ||
+ args.anilistStatus ||
+ args.anilistLogout ||
+ args.anilistSetup ||
+ args.anilistRetryQueue ||
+ args.jellyfin ||
+ args.jellyfinLogin ||
+ args.jellyfinLogout ||
+ args.jellyfinLibraries ||
+ args.jellyfinItems ||
+ args.jellyfinSubtitles ||
+ args.jellyfinPlay ||
+ args.jellyfinRemoteAnnounce ||
+ args.texthooker ||
+ args.generateConfig ||
+ args.help
+ );
+}
+
+export function shouldStartApp(args: CliArgs): boolean {
+ if (args.stop && !args.start) return false;
+ if (
+ args.background ||
+ args.start ||
+ args.toggle ||
+ args.toggleVisibleOverlay ||
+ args.toggleInvisibleOverlay ||
+ args.copySubtitle ||
+ args.copySubtitleMultiple ||
+ args.mineSentence ||
+ args.mineSentenceMultiple ||
+ args.updateLastCardFromClipboard ||
+ args.toggleSecondarySub ||
+ args.triggerFieldGrouping ||
+ args.triggerSubsync ||
+ args.markAudioCard ||
+ args.openRuntimeOptions ||
+ args.jellyfin ||
+ args.jellyfinPlay ||
+ args.texthooker
+ ) {
+ return true;
+ }
+ return false;
+}
+
+export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
+ return (
+ args.toggle ||
+ args.toggleVisibleOverlay ||
+ args.toggleInvisibleOverlay ||
+ args.show ||
+ args.hide ||
+ args.showVisibleOverlay ||
+ args.hideVisibleOverlay ||
+ args.showInvisibleOverlay ||
+ args.hideInvisibleOverlay ||
+ args.copySubtitle ||
+ args.copySubtitleMultiple ||
+ args.mineSentence ||
+ args.mineSentenceMultiple ||
+ args.updateLastCardFromClipboard ||
+ args.toggleSecondarySub ||
+ args.triggerFieldGrouping ||
+ args.triggerSubsync ||
+ args.markAudioCard ||
+ args.openRuntimeOptions
+ );
+}
diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts
new file mode 100644
index 0000000..c60bcc5
--- /dev/null
+++ b/src/cli/help.test.ts
@@ -0,0 +1,27 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { printHelp } from './help';
+
+test('printHelp includes configured texthooker port', () => {
+ const original = console.log;
+ let output = '';
+ console.log = (value?: unknown) => {
+ output += String(value);
+ };
+
+ try {
+ printHelp(7777);
+ } finally {
+ console.log = original;
+ }
+
+ assert.match(output, /--help\s+Show this help/);
+ assert.match(output, /default: 7777/);
+ assert.match(output, /--refresh-known-words/);
+ assert.match(output, /--anilist-status/);
+ assert.match(output, /--anilist-retry-queue/);
+ assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
+ assert.match(output, /--jellyfin-login/);
+ assert.match(output, /--jellyfin-subtitles/);
+ assert.match(output, /--jellyfin-play/);
+});
diff --git a/src/cli/help.ts b/src/cli/help.ts
new file mode 100644
index 0000000..ecdb0e1
--- /dev/null
+++ b/src/cli/help.ts
@@ -0,0 +1,80 @@
+export function printHelp(defaultTexthookerPort: number): void {
+ const tty = process.stdout?.isTTY ?? false;
+ const B = tty ? '\x1b[1m' : '';
+ const D = tty ? '\x1b[2m' : '';
+ const R = tty ? '\x1b[0m' : '';
+
+ console.log(`
+${B}SubMiner${R} — Japanese sentence mining with mpv + Yomitan
+
+${B}Usage:${R} subminer ${D}[command] [options]${R}
+
+${B}Session${R}
+ --background Start in tray/background mode
+ --start Connect to mpv and launch overlay
+ --stop Stop the running instance
+ --texthooker Start texthooker server only ${D}(no overlay)${R}
+
+${B}Overlay${R}
+ --toggle-visible-overlay Toggle subtitle overlay
+ --toggle-invisible-overlay Toggle interactive overlay ${D}(Yomitan lookup)${R}
+ --show-visible-overlay Show subtitle overlay
+ --hide-visible-overlay Hide subtitle overlay
+ --show-invisible-overlay Show interactive overlay
+ --hide-invisible-overlay Hide interactive overlay
+ --settings Open Yomitan settings window
+ --auto-start-overlay Auto-hide mpv subs, show overlay on connect
+
+${B}Mining${R}
+ --mine-sentence Create Anki card from current subtitle
+ --mine-sentence-multiple Select multiple lines, then mine
+ --copy-subtitle Copy current subtitle to clipboard
+ --copy-subtitle-multiple Enter multi-line copy mode
+ --update-last-card-from-clipboard Update last Anki card from clipboard
+ --mark-audio-card Mark last card as audio-only
+ --trigger-field-grouping Run Kiku field grouping
+ --trigger-subsync Run subtitle sync
+ --toggle-secondary-sub Cycle secondary subtitle mode
+ --refresh-known-words Refresh known words cache
+ --open-runtime-options Open runtime options palette
+
+${B}AniList${R}
+ --anilist-setup Open AniList authentication flow
+ --anilist-status Show token and retry queue status
+ --anilist-logout Clear stored AniList token
+ --anilist-retry-queue Retry next queued update
+
+${B}Jellyfin${R}
+ --jellyfin Open Jellyfin setup window
+ --jellyfin-login Authenticate and store session token
+ --jellyfin-logout Clear stored session data
+ --jellyfin-libraries List available libraries
+ --jellyfin-items List items from a library
+ --jellyfin-subtitles List subtitle tracks for an item
+ --jellyfin-subtitle-urls Print subtitle download URLs only
+ --jellyfin-play Stream an item in mpv
+ --jellyfin-remote-announce Broadcast cast-target capability
+
+ ${D}Jellyfin options:${R}
+ --jellyfin-server ${D}URL${R} Server URL ${D}(overrides config)${R}
+ --jellyfin-username ${D}NAME${R} Username for login
+ --jellyfin-password ${D}PASS${R} Password for login
+ --jellyfin-library-id ${D}ID${R} Library to browse
+ --jellyfin-item-id ${D}ID${R} Item to play or inspect
+ --jellyfin-search ${D}QUERY${R} Filter items by search term
+ --jellyfin-limit ${D}N${R} Max items returned
+ --jellyfin-audio-stream-index ${D}N${R} Audio stream override
+ --jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
+
+${B}Options${R}
+ --socket ${D}PATH${R} mpv IPC socket path
+ --backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos)${R}
+ --port ${D}PORT${R} Texthooker server port ${D}(default: ${defaultTexthookerPort})${R}
+ --log-level ${D}LEVEL${R} ${D}debug | info | warn | error${R}
+ --debug Enable debug mode ${D}(alias: --dev)${R}
+ --generate-config Write default config.jsonc
+ --config-path ${D}PATH${R} Target path for --generate-config
+ --backup-overwrite Backup existing config before overwrite
+ --help Show this help
+`);
+}
diff --git a/src/config/config.test.ts b/src/config/config.test.ts
new file mode 100644
index 0000000..ed35864
--- /dev/null
+++ b/src/config/config.test.ts
@@ -0,0 +1,1112 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { ConfigService, ConfigStartupParseError } from './service';
+import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY } from './definitions';
+import { generateConfigTemplate } from './template';
+
+function makeTempDir(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
+}
+
+test('loads defaults when config is missing', () => {
+ const dir = makeTempDir();
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
+ assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
+ assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
+ assert.equal(config.anilist.enabled, false);
+ assert.equal(config.jellyfin.remoteControlEnabled, true);
+ assert.equal(config.jellyfin.remoteControlAutoConnect, true);
+ assert.equal(config.jellyfin.autoAnnounce, false);
+ assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
+ assert.equal(config.discordPresence.enabled, false);
+ assert.equal(config.discordPresence.updateIntervalMs, 3_000);
+ assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
+ assert.equal(config.subtitleStyle.preserveLineBreaks, false);
+ assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6');
+ assert.equal(config.immersionTracking.enabled, true);
+ assert.equal(config.immersionTracking.dbPath, '');
+ assert.equal(config.immersionTracking.batchSize, 25);
+ assert.equal(config.immersionTracking.flushIntervalMs, 500);
+ assert.equal(config.immersionTracking.queueCap, 1000);
+ assert.equal(config.immersionTracking.payloadCapBytes, 256);
+ assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
+ assert.equal(config.immersionTracking.retention.eventsDays, 7);
+ assert.equal(config.immersionTracking.retention.telemetryDays, 30);
+ assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
+ assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
+ assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
+});
+
+test('throws actionable startup parse error for malformed config at construction time', () => {
+ const dir = makeTempDir();
+ const configPath = path.join(dir, 'config.jsonc');
+ fs.writeFileSync(configPath, '{"websocket":', 'utf-8');
+
+ assert.throws(
+ () => new ConfigService(dir),
+ (error: unknown) => {
+ assert.ok(error instanceof ConfigStartupParseError);
+ assert.equal(error.path, configPath);
+ assert.ok(error.parseError.length > 0);
+ assert.ok(error.message.includes(configPath));
+ assert.ok(error.message.includes(error.parseError));
+ return true;
+ },
+ );
+});
+
+test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
+ const validDir = makeTempDir();
+ fs.writeFileSync(
+ path.join(validDir, 'config.jsonc'),
+ `{
+ "subtitleStyle": {
+ "preserveLineBreaks": true
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const validService = new ConfigService(validDir);
+ assert.equal(validService.getConfig().subtitleStyle.preserveLineBreaks, true);
+
+ const invalidDir = makeTempDir();
+ fs.writeFileSync(
+ path.join(invalidDir, 'config.jsonc'),
+ `{
+ "subtitleStyle": {
+ "preserveLineBreaks": "yes"
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const invalidService = new ConfigService(invalidDir);
+ assert.equal(
+ invalidService.getConfig().subtitleStyle.preserveLineBreaks,
+ DEFAULT_CONFIG.subtitleStyle.preserveLineBreaks,
+ );
+ assert.ok(
+ invalidService
+ .getWarnings()
+ .some((warning) => warning.path === 'subtitleStyle.preserveLineBreaks'),
+ );
+});
+
+test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
+ const validDir = makeTempDir();
+ fs.writeFileSync(
+ path.join(validDir, 'config.jsonc'),
+ `{
+ "subtitleStyle": {
+ "hoverTokenColor": "#c6a0f6"
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const validService = new ConfigService(validDir);
+ assert.equal(validService.getConfig().subtitleStyle.hoverTokenColor, '#c6a0f6');
+
+ const invalidDir = makeTempDir();
+ fs.writeFileSync(
+ path.join(invalidDir, 'config.jsonc'),
+ `{
+ "subtitleStyle": {
+ "hoverTokenColor": "purple"
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const invalidService = new ConfigService(invalidDir);
+ assert.equal(
+ invalidService.getConfig().subtitleStyle.hoverTokenColor,
+ DEFAULT_CONFIG.subtitleStyle.hoverTokenColor,
+ );
+ assert.ok(
+ invalidService
+ .getWarnings()
+ .some((warning) => warning.path === 'subtitleStyle.hoverTokenColor'),
+ );
+});
+
+test('parses anilist.enabled and warns for invalid value', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "anilist": {
+ "enabled": "yes"
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(config.anilist.enabled, DEFAULT_CONFIG.anilist.enabled);
+ assert.ok(warnings.some((warning) => warning.path === 'anilist.enabled'));
+
+ service.patchRawConfig({ anilist: { enabled: true } });
+ assert.equal(service.getConfig().anilist.enabled, true);
+});
+
+test('parses jellyfin remote control fields', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "jellyfin": {
+ "enabled": true,
+ "serverUrl": "http://127.0.0.1:8096",
+ "remoteControlEnabled": true,
+ "remoteControlAutoConnect": true,
+ "autoAnnounce": true,
+ "remoteControlDeviceName": "SubMiner"
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.equal(config.jellyfin.enabled, true);
+ assert.equal(config.jellyfin.serverUrl, 'http://127.0.0.1:8096');
+ assert.equal(config.jellyfin.remoteControlEnabled, true);
+ assert.equal(config.jellyfin.remoteControlAutoConnect, true);
+ assert.equal(config.jellyfin.autoAnnounce, true);
+ assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
+});
+
+test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => {
+ const disabledDir = makeTempDir();
+ fs.writeFileSync(
+ path.join(disabledDir, 'config.jsonc'),
+ `{
+ "jellyfin": {
+ "enabled": false,
+ "remoteControlEnabled": false
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const disabledService = new ConfigService(disabledDir);
+ const disabledConfig = disabledService.getConfig();
+ assert.equal(disabledConfig.jellyfin.enabled, false);
+ assert.equal(disabledConfig.jellyfin.remoteControlEnabled, false);
+ assert.equal(
+ disabledService
+ .getWarnings()
+ .some(
+ (warning) =>
+ warning.path === 'jellyfin.enabled' || warning.path === 'jellyfin.remoteControlEnabled',
+ ),
+ false,
+ );
+
+ const mixedDir = makeTempDir();
+ fs.writeFileSync(
+ path.join(mixedDir, 'config.jsonc'),
+ `{
+ "jellyfin": {
+ "enabled": true,
+ "remoteControlEnabled": false
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const mixedService = new ConfigService(mixedDir);
+ const mixedConfig = mixedService.getConfig();
+ assert.equal(mixedConfig.jellyfin.enabled, true);
+ assert.equal(mixedConfig.jellyfin.remoteControlEnabled, false);
+ assert.equal(
+ mixedService
+ .getWarnings()
+ .some(
+ (warning) =>
+ warning.path === 'jellyfin.enabled' || warning.path === 'jellyfin.remoteControlEnabled',
+ ),
+ false,
+ );
+});
+
+test('parses discordPresence fields and warns for invalid types', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "discordPresence": {
+ "enabled": true,
+ "updateIntervalMs": 3000,
+ "debounceMs": 250
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ assert.equal(config.discordPresence.enabled, true);
+ assert.equal(config.discordPresence.updateIntervalMs, 3000);
+ assert.equal(config.discordPresence.debounceMs, 250);
+
+ service.patchRawConfig({ discordPresence: { enabled: 'yes' as never } });
+ assert.equal(service.getConfig().discordPresence.enabled, DEFAULT_CONFIG.discordPresence.enabled);
+ assert.ok(service.getWarnings().some((warning) => warning.path === 'discordPresence.enabled'));
+});
+
+test('accepts immersion tracking config values', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "immersionTracking": {
+ "enabled": false,
+ "dbPath": "/tmp/immersions/custom.sqlite",
+ "batchSize": 50,
+ "flushIntervalMs": 750,
+ "queueCap": 2000,
+ "payloadCapBytes": 512,
+ "maintenanceIntervalMs": 3600000,
+ "retention": {
+ "eventsDays": 14,
+ "telemetryDays": 45,
+ "dailyRollupsDays": 730,
+ "monthlyRollupsDays": 3650,
+ "vacuumIntervalDays": 14
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.equal(config.immersionTracking.enabled, false);
+ assert.equal(config.immersionTracking.dbPath, '/tmp/immersions/custom.sqlite');
+ assert.equal(config.immersionTracking.batchSize, 50);
+ assert.equal(config.immersionTracking.flushIntervalMs, 750);
+ assert.equal(config.immersionTracking.queueCap, 2000);
+ assert.equal(config.immersionTracking.payloadCapBytes, 512);
+ assert.equal(config.immersionTracking.maintenanceIntervalMs, 3_600_000);
+ assert.equal(config.immersionTracking.retention.eventsDays, 14);
+ assert.equal(config.immersionTracking.retention.telemetryDays, 45);
+ assert.equal(config.immersionTracking.retention.dailyRollupsDays, 730);
+ assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 3650);
+ assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 14);
+});
+
+test('falls back for invalid immersion tracking tuning values', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "immersionTracking": {
+ "batchSize": 0,
+ "flushIntervalMs": 1,
+ "queueCap": 5,
+ "payloadCapBytes": 16,
+ "maintenanceIntervalMs": 1000,
+ "retention": {
+ "eventsDays": 0,
+ "telemetryDays": 99999,
+ "dailyRollupsDays": 0,
+ "monthlyRollupsDays": 999999,
+ "vacuumIntervalDays": 0
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(config.immersionTracking.batchSize, 25);
+ assert.equal(config.immersionTracking.flushIntervalMs, 500);
+ assert.equal(config.immersionTracking.queueCap, 1000);
+ assert.equal(config.immersionTracking.payloadCapBytes, 256);
+ assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
+ assert.equal(config.immersionTracking.retention.eventsDays, 7);
+ assert.equal(config.immersionTracking.retention.telemetryDays, 30);
+ assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
+ assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
+ assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
+
+ assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.batchSize'));
+ assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.flushIntervalMs'));
+ assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.queueCap'));
+ assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.payloadCapBytes'));
+ assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.maintenanceIntervalMs'));
+ assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.retention.eventsDays'));
+ assert.ok(
+ warnings.some((warning) => warning.path === 'immersionTracking.retention.telemetryDays'),
+ );
+ assert.ok(
+ warnings.some((warning) => warning.path === 'immersionTracking.retention.dailyRollupsDays'),
+ );
+ assert.ok(
+ warnings.some((warning) => warning.path === 'immersionTracking.retention.monthlyRollupsDays'),
+ );
+ assert.ok(
+ warnings.some((warning) => warning.path === 'immersionTracking.retention.vacuumIntervalDays'),
+ );
+});
+
+test('parses jsonc and warns/falls back on invalid value', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ // invalid websocket port
+ "websocket": { "port": "bad" }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
+ assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port'));
+});
+
+test('accepts trailing commas in jsonc', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "websocket": {
+ "enabled": "auto",
+ "port": 7788,
+ },
+ "youtubeSubgen": {
+ "primarySubLanguages": ["ja", "en",],
+ },
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ assert.equal(config.websocket.port, 7788);
+ assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'en']);
+});
+
+test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
+ const dir = makeTempDir();
+ const configPath = path.join(dir, 'config.jsonc');
+ fs.writeFileSync(
+ configPath,
+ `{
+ "logging": {
+ "level": "warn"
+ }
+ }`,
+ );
+
+ const service = new ConfigService(dir);
+ assert.equal(service.getConfig().logging.level, 'warn');
+
+ fs.writeFileSync(
+ configPath,
+ `{
+ "logging":`,
+ );
+
+ const result = service.reloadConfigStrict();
+ assert.equal(result.ok, false);
+ if (result.ok) {
+ throw new Error('Expected strict reload to fail on invalid JSONC.');
+ }
+ assert.equal(result.path, configPath);
+ assert.equal(service.getConfig().logging.level, 'warn');
+});
+
+test('reloadConfigStrict rejects invalid json and preserves previous config', () => {
+ const dir = makeTempDir();
+ const configPath = path.join(dir, 'config.json');
+ fs.writeFileSync(configPath, JSON.stringify({ logging: { level: 'error' } }, null, 2));
+
+ const service = new ConfigService(dir);
+ assert.equal(service.getConfig().logging.level, 'error');
+
+ fs.writeFileSync(configPath, '{"logging":');
+
+ const result = service.reloadConfigStrict();
+ assert.equal(result.ok, false);
+ if (result.ok) {
+ throw new Error('Expected strict reload to fail on invalid JSON.');
+ }
+ assert.equal(result.path, configPath);
+ 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(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "logging": {
+ "level": "warn"
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.equal(config.logging.level, 'warn');
+});
+
+test('falls back for invalid logging.level and reports warning', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "logging": {
+ "level": "trace"
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level);
+ assert.ok(warnings.some((warning) => warning.path === 'logging.level'));
+});
+
+test('warns and ignores unknown top-level config keys', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "websocket": {
+ "port": 7788
+ },
+ "unknownFeatureFlag": {
+ "enabled": true
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(config.websocket.port, 7788);
+ assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag'));
+});
+
+test('parses invisible overlay config and new global shortcuts', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "shortcuts": {
+ "toggleVisibleOverlayGlobal": "Alt+Shift+U",
+ "toggleInvisibleOverlayGlobal": "Alt+Shift+I",
+ "openJimaku": "Ctrl+Alt+J"
+ },
+ "invisibleOverlay": {
+ "startupVisibility": "hidden"
+ },
+ "bind_visible_overlay_to_mpv_sub_visibility": false,
+ "youtubeSubgen": {
+ "primarySubLanguages": ["ja", "jpn", "jp"]
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
+ assert.equal(config.shortcuts.toggleInvisibleOverlayGlobal, 'Alt+Shift+I');
+ assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
+ assert.equal(config.invisibleOverlay.startupVisibility, 'hidden');
+ assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
+ assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']);
+});
+
+test('runtime options registry is centralized', () => {
+ const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
+ assert.deepEqual(ids, [
+ 'anki.autoUpdateNewCards',
+ 'anki.nPlusOneMatchMode',
+ 'anki.kikuFieldGrouping',
+ ]);
+});
+
+test('validates ankiConnect n+1 behavior values', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "highlightEnabled": "yes",
+ "refreshMinutes": -5
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(
+ config.ankiConnect.nPlusOne.highlightEnabled,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
+ );
+ assert.equal(
+ config.ankiConnect.nPlusOne.refreshMinutes,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
+ );
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'));
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.refreshMinutes'));
+});
+
+test('accepts valid ankiConnect n+1 behavior values', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "highlightEnabled": true,
+ "refreshMinutes": 120
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
+ assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 120);
+});
+
+test('validates ankiConnect n+1 minimum sentence word count', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "minSentenceWords": 0
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(
+ config.ankiConnect.nPlusOne.minSentenceWords,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
+ );
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords'));
+});
+
+test('accepts valid ankiConnect n+1 minimum sentence word count', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "minSentenceWords": 4
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.equal(config.ankiConnect.nPlusOne.minSentenceWords, 4);
+});
+
+test('validates ankiConnect n+1 match mode values', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "matchMode": "bad-mode"
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(
+ config.ankiConnect.nPlusOne.matchMode,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
+ );
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.matchMode'));
+});
+
+test('accepts valid ankiConnect n+1 match mode values', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "matchMode": "surface"
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface');
+});
+
+test('validates ankiConnect n+1 color values', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "nPlusOne": "not-a-color",
+ "knownWord": 123
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
+ assert.equal(
+ config.ankiConnect.nPlusOne.knownWord,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord,
+ );
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.knownWord'));
+});
+
+test('accepts valid ankiConnect n+1 color values', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "nPlusOne": "#c6a0f6",
+ "knownWord": "#a6da95"
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
+ assert.equal(config.ankiConnect.nPlusOne.knownWord, '#a6da95');
+});
+
+test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "behavior": {
+ "nPlusOneHighlightEnabled": true,
+ "nPlusOneRefreshMinutes": 90,
+ "nPlusOneMatchMode": "surface"
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
+ assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 90);
+ assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface');
+ assert.ok(
+ warnings.some(
+ (warning) =>
+ warning.path === 'ankiConnect.behavior.nPlusOneHighlightEnabled' ||
+ warning.path === 'ankiConnect.behavior.nPlusOneRefreshMinutes' ||
+ warning.path === 'ankiConnect.behavior.nPlusOneMatchMode',
+ ),
+ );
+});
+
+test('warns when ankiConnect.openRouter is used and migrates to ai', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "openRouter": {
+ "model": "openrouter/test-model"
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal((config.ankiConnect.ai as Record).model, 'openrouter/test-model');
+ assert.ok(
+ warnings.some(
+ (warning) =>
+ warning.path === 'ankiConnect.openRouter' && warning.message.includes('ankiConnect.ai'),
+ ),
+ );
+});
+
+test('falls back and warns when legacy ankiConnect migration values are invalid', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "audioField": 123,
+ "generateAudio": "yes",
+ "imageType": "gif",
+ "imageQuality": -1,
+ "mediaInsertMode": "middle",
+ "notificationType": "toast"
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.equal(config.ankiConnect.fields.audio, DEFAULT_CONFIG.ankiConnect.fields.audio);
+ assert.equal(
+ config.ankiConnect.media.generateAudio,
+ DEFAULT_CONFIG.ankiConnect.media.generateAudio,
+ );
+ assert.equal(config.ankiConnect.media.imageType, DEFAULT_CONFIG.ankiConnect.media.imageType);
+ assert.equal(
+ config.ankiConnect.media.imageQuality,
+ DEFAULT_CONFIG.ankiConnect.media.imageQuality,
+ );
+ assert.equal(
+ config.ankiConnect.behavior.mediaInsertMode,
+ DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode,
+ );
+ assert.equal(
+ config.ankiConnect.behavior.notificationType,
+ DEFAULT_CONFIG.ankiConnect.behavior.notificationType,
+ );
+
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.audioField'));
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.generateAudio'));
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageType'));
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageQuality'));
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.mediaInsertMode'));
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.notificationType'));
+});
+
+test('maps valid legacy ankiConnect values to equivalent modern config', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "audioField": "AudioLegacy",
+ "imageField": "ImageLegacy",
+ "generateAudio": false,
+ "imageType": "avif",
+ "imageFormat": "webp",
+ "imageQuality": 88,
+ "mediaInsertMode": "prepend",
+ "notificationType": "both",
+ "autoUpdateNewCards": false
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.equal(config.ankiConnect.fields.audio, 'AudioLegacy');
+ assert.equal(config.ankiConnect.fields.image, 'ImageLegacy');
+ assert.equal(config.ankiConnect.media.generateAudio, false);
+ assert.equal(config.ankiConnect.media.imageType, 'avif');
+ assert.equal(config.ankiConnect.media.imageFormat, 'webp');
+ assert.equal(config.ankiConnect.media.imageQuality, 88);
+ assert.equal(config.ankiConnect.behavior.mediaInsertMode, 'prepend');
+ assert.equal(config.ankiConnect.behavior.notificationType, 'both');
+ assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, false);
+});
+
+test('ignores deprecated isLapis sentence-card field overrides', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "isLapis": {
+ "enabled": true,
+ "sentenceCardModel": "Japanese sentences",
+ "sentenceCardSentenceField": "CustomSentence",
+ "sentenceCardAudioField": "CustomAudio"
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ const lapisConfig = config.ankiConnect.isLapis as Record;
+ assert.equal(lapisConfig.sentenceCardSentenceField, undefined);
+ assert.equal(lapisConfig.sentenceCardAudioField, undefined);
+ assert.ok(
+ warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardSentenceField'),
+ );
+ assert.ok(
+ warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardAudioField'),
+ );
+});
+
+test('accepts valid ankiConnect n+1 deck list', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "decks": ["Deck One", "Deck Two"]
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.deepEqual(config.ankiConnect.nPlusOne.decks, ['Deck One', 'Deck Two']);
+});
+
+test('accepts valid ankiConnect tags list', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "tags": ["SubMiner", "Mining"]
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+
+ assert.deepEqual(config.ankiConnect.tags, ['SubMiner', 'Mining']);
+});
+
+test('falls back to default when ankiConnect tags list is invalid', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "tags": ["SubMiner", 123]
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags'));
+});
+
+test('falls back to default when ankiConnect n+1 deck list is invalid', () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ `{
+ "ankiConnect": {
+ "nPlusOne": {
+ "decks": "not-an-array"
+ }
+ }
+ }`,
+ 'utf-8',
+ );
+
+ const service = new ConfigService(dir);
+ const config = service.getConfig();
+ const warnings = service.getWarnings();
+
+ assert.deepEqual(config.ankiConnect.nPlusOne.decks, []);
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks'));
+});
+
+test('template generator includes known keys', () => {
+ const output = generateConfigTemplate(DEFAULT_CONFIG);
+ assert.match(output, /"ankiConnect":/);
+ assert.match(output, /"logging":/);
+ assert.match(output, /"websocket":/);
+ assert.match(output, /"discordPresence":/);
+ assert.match(output, /"youtubeSubgen":/);
+ assert.match(output, /"preserveLineBreaks": false/);
+ assert.match(output, /"nPlusOne"\s*:\s*\{/);
+ assert.match(output, /"nPlusOne": "#c6a0f6"/);
+ assert.match(output, /"knownWord": "#a6da95"/);
+ assert.match(output, /"minSentenceWords": 3/);
+ assert.match(output, /auto-generated from src\/config\/definitions.ts/);
+ assert.match(
+ output,
+ /"level": "info",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
+ );
+ assert.match(
+ output,
+ /"enabled": "auto",? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/,
+ );
+ assert.match(
+ output,
+ /"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
+ );
+});
diff --git a/src/config/definitions.ts b/src/config/definitions.ts
new file mode 100644
index 0000000..f471b4e
--- /dev/null
+++ b/src/config/definitions.ts
@@ -0,0 +1,101 @@
+import { RawConfig, ResolvedConfig } from '../types';
+import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core';
+import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion';
+import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations';
+import { SUBTITLE_DEFAULT_CONFIG } from './definitions/defaults-subtitle';
+import { buildCoreConfigOptionRegistry } from './definitions/options-core';
+import { buildImmersionConfigOptionRegistry } from './definitions/options-immersion';
+import { buildIntegrationConfigOptionRegistry } from './definitions/options-integrations';
+import { buildSubtitleConfigOptionRegistry } from './definitions/options-subtitle';
+import { buildRuntimeOptionRegistry } from './definitions/runtime-options';
+import { CONFIG_TEMPLATE_SECTIONS } from './definitions/template-sections';
+
+export { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from './definitions/shared';
+export type {
+ ConfigOptionRegistryEntry,
+ ConfigTemplateSection,
+ ConfigValueKind,
+ RuntimeOptionRegistryEntry,
+} from './definitions/shared';
+
+const {
+ subtitlePosition,
+ keybindings,
+ websocket,
+ logging,
+ texthooker,
+ shortcuts,
+ secondarySub,
+ subsync,
+ auto_start_overlay,
+ bind_visible_overlay_to_mpv_sub_visibility,
+ invisibleOverlay,
+} = CORE_DEFAULT_CONFIG;
+const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } =
+ INTEGRATIONS_DEFAULT_CONFIG;
+const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
+const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
+
+export const DEFAULT_CONFIG: ResolvedConfig = {
+ subtitlePosition,
+ keybindings,
+ websocket,
+ logging,
+ texthooker,
+ ankiConnect,
+ shortcuts,
+ secondarySub,
+ subsync,
+ subtitleStyle,
+ auto_start_overlay,
+ bind_visible_overlay_to_mpv_sub_visibility,
+ jimaku,
+ anilist,
+ jellyfin,
+ discordPresence,
+ youtubeSubgen,
+ invisibleOverlay,
+ immersionTracking,
+};
+
+export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
+
+export const RUNTIME_OPTION_REGISTRY = buildRuntimeOptionRegistry(DEFAULT_CONFIG);
+
+export const CONFIG_OPTION_REGISTRY = [
+ ...buildCoreConfigOptionRegistry(DEFAULT_CONFIG),
+ ...buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
+ ...buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
+ ...buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
+];
+
+export { CONFIG_TEMPLATE_SECTIONS };
+
+export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {
+ return JSON.parse(JSON.stringify(config)) as ResolvedConfig;
+}
+
+export function deepMergeRawConfig(base: RawConfig, patch: RawConfig): RawConfig {
+ const clone = JSON.parse(JSON.stringify(base)) as Record;
+ const patchObject = patch as Record;
+
+ const mergeInto = (target: Record, source: Record): void => {
+ for (const [key, value] of Object.entries(source)) {
+ if (
+ value !== null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ typeof target[key] === 'object' &&
+ target[key] !== null &&
+ !Array.isArray(target[key])
+ ) {
+ mergeInto(target[key] as Record, value as Record);
+ } else {
+ target[key] = value;
+ }
+ }
+ };
+
+ mergeInto(clone, patchObject);
+ return clone as RawConfig;
+}
diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts
new file mode 100644
index 0000000..9097066
--- /dev/null
+++ b/src/config/definitions/defaults-core.ts
@@ -0,0 +1,61 @@
+import { ResolvedConfig } from '../../types';
+
+export const CORE_DEFAULT_CONFIG: Pick<
+ ResolvedConfig,
+ | 'subtitlePosition'
+ | 'keybindings'
+ | 'websocket'
+ | 'logging'
+ | 'texthooker'
+ | 'shortcuts'
+ | 'secondarySub'
+ | 'subsync'
+ | 'auto_start_overlay'
+ | 'bind_visible_overlay_to_mpv_sub_visibility'
+ | 'invisibleOverlay'
+> = {
+ subtitlePosition: { yPercent: 10 },
+ keybindings: [],
+ websocket: {
+ enabled: 'auto',
+ port: 6677,
+ },
+ logging: {
+ level: 'info',
+ },
+ texthooker: {
+ openBrowser: true,
+ },
+ shortcuts: {
+ toggleVisibleOverlayGlobal: 'Alt+Shift+O',
+ toggleInvisibleOverlayGlobal: 'Alt+Shift+I',
+ copySubtitle: 'CommandOrControl+C',
+ copySubtitleMultiple: 'CommandOrControl+Shift+C',
+ updateLastCardFromClipboard: 'CommandOrControl+V',
+ triggerFieldGrouping: 'CommandOrControl+G',
+ triggerSubsync: 'Ctrl+Alt+S',
+ mineSentence: 'CommandOrControl+S',
+ mineSentenceMultiple: 'CommandOrControl+Shift+S',
+ multiCopyTimeoutMs: 3000,
+ toggleSecondarySub: 'CommandOrControl+Shift+V',
+ markAudioCard: 'CommandOrControl+Shift+A',
+ openRuntimeOptions: 'CommandOrControl+Shift+O',
+ openJimaku: 'Ctrl+Shift+J',
+ },
+ secondarySub: {
+ secondarySubLanguages: [],
+ autoLoadSecondarySub: false,
+ defaultMode: 'hover',
+ },
+ subsync: {
+ defaultMode: 'auto',
+ alass_path: '',
+ ffsubsync_path: '',
+ ffmpeg_path: '',
+ },
+ auto_start_overlay: false,
+ bind_visible_overlay_to_mpv_sub_visibility: true,
+ invisibleOverlay: {
+ startupVisibility: 'platform-default',
+ },
+};
diff --git a/src/config/definitions/defaults-immersion.ts b/src/config/definitions/defaults-immersion.ts
new file mode 100644
index 0000000..f648739
--- /dev/null
+++ b/src/config/definitions/defaults-immersion.ts
@@ -0,0 +1,20 @@
+import { ResolvedConfig } from '../../types';
+
+export const IMMERSION_DEFAULT_CONFIG: Pick = {
+ immersionTracking: {
+ enabled: true,
+ dbPath: '',
+ batchSize: 25,
+ flushIntervalMs: 500,
+ queueCap: 1000,
+ payloadCapBytes: 256,
+ maintenanceIntervalMs: 24 * 60 * 60 * 1000,
+ retention: {
+ eventsDays: 7,
+ telemetryDays: 30,
+ dailyRollupsDays: 365,
+ monthlyRollupsDays: 5 * 365,
+ vacuumIntervalDays: 7,
+ },
+ },
+};
diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts
new file mode 100644
index 0000000..662265a
--- /dev/null
+++ b/src/config/definitions/defaults-integrations.ts
@@ -0,0 +1,113 @@
+import { ResolvedConfig } from '../../types';
+
+export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
+ ResolvedConfig,
+ 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'youtubeSubgen'
+> = {
+ ankiConnect: {
+ enabled: false,
+ url: 'http://127.0.0.1:8765',
+ pollingRate: 3000,
+ tags: ['SubMiner'],
+ fields: {
+ audio: 'ExpressionAudio',
+ image: 'Picture',
+ sentence: 'Sentence',
+ miscInfo: 'MiscInfo',
+ translation: 'SelectionText',
+ },
+ ai: {
+ enabled: false,
+ alwaysUseAiTranslation: false,
+ apiKey: '',
+ model: 'openai/gpt-4o-mini',
+ baseUrl: 'https://openrouter.ai/api',
+ targetLanguage: 'English',
+ systemPrompt:
+ 'You are a translation engine. Return only the translated text with no explanations.',
+ },
+ media: {
+ generateAudio: true,
+ generateImage: true,
+ imageType: 'static',
+ imageFormat: 'jpg',
+ imageQuality: 92,
+ imageMaxWidth: undefined,
+ imageMaxHeight: undefined,
+ animatedFps: 10,
+ animatedMaxWidth: 640,
+ animatedMaxHeight: undefined,
+ animatedCrf: 35,
+ audioPadding: 0.5,
+ fallbackDuration: 3.0,
+ maxMediaDuration: 30,
+ },
+ behavior: {
+ overwriteAudio: true,
+ overwriteImage: true,
+ mediaInsertMode: 'append',
+ highlightWord: true,
+ notificationType: 'osd',
+ autoUpdateNewCards: true,
+ },
+ nPlusOne: {
+ highlightEnabled: false,
+ refreshMinutes: 1440,
+ matchMode: 'headword',
+ decks: [],
+ minSentenceWords: 3,
+ nPlusOne: '#c6a0f6',
+ knownWord: '#a6da95',
+ },
+ metadata: {
+ pattern: '[SubMiner] %f (%t)',
+ },
+ isLapis: {
+ enabled: false,
+ sentenceCardModel: 'Japanese sentences',
+ },
+ isKiku: {
+ enabled: false,
+ fieldGrouping: 'disabled',
+ deleteDuplicateInAuto: true,
+ },
+ },
+ jimaku: {
+ apiBaseUrl: 'https://jimaku.cc',
+ languagePreference: 'ja',
+ maxEntryResults: 10,
+ },
+ anilist: {
+ enabled: false,
+ accessToken: '',
+ },
+ jellyfin: {
+ enabled: false,
+ serverUrl: '',
+ username: '',
+ deviceId: 'subminer',
+ clientName: 'SubMiner',
+ clientVersion: '0.1.0',
+ defaultLibraryId: '',
+ remoteControlEnabled: true,
+ remoteControlAutoConnect: true,
+ autoAnnounce: false,
+ remoteControlDeviceName: 'SubMiner',
+ pullPictures: false,
+ iconCacheDir: '/tmp/subminer-jellyfin-icons',
+ directPlayPreferred: true,
+ directPlayContainers: ['mkv', 'mp4', 'webm', 'mov', 'flac', 'mp3', 'aac'],
+ transcodeVideoCodec: 'h264',
+ },
+ discordPresence: {
+ enabled: false,
+ updateIntervalMs: 3_000,
+ debounceMs: 750,
+ },
+ youtubeSubgen: {
+ mode: 'automatic',
+ whisperBin: '',
+ whisperModel: '',
+ primarySubLanguages: ['ja', 'jpn'],
+ },
+};
diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts
new file mode 100644
index 0000000..00706e9
--- /dev/null
+++ b/src/config/definitions/defaults-subtitle.ts
@@ -0,0 +1,42 @@
+import { ResolvedConfig } from '../../types';
+
+export const SUBTITLE_DEFAULT_CONFIG: Pick = {
+ subtitleStyle: {
+ enableJlpt: false,
+ preserveLineBreaks: false,
+ hoverTokenColor: '#c6a0f6',
+ fontFamily:
+ 'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
+ fontSize: 35,
+ fontColor: '#cad3f5',
+ fontWeight: 'normal',
+ fontStyle: 'normal',
+ backgroundColor: 'rgb(30, 32, 48, 0.88)',
+ nPlusOneColor: '#c6a0f6',
+ knownWordColor: '#a6da95',
+ jlptColors: {
+ N1: '#ed8796',
+ N2: '#f5a97f',
+ N3: '#f9e2af',
+ N4: '#a6e3a1',
+ N5: '#8aadf4',
+ },
+ frequencyDictionary: {
+ enabled: false,
+ sourcePath: '',
+ topX: 1000,
+ mode: 'single',
+ singleColor: '#f5a97f',
+ bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
+ },
+ secondary: {
+ fontSize: 24,
+ fontColor: '#ffffff',
+ backgroundColor: 'transparent',
+ fontWeight: 'normal',
+ fontStyle: 'normal',
+ fontFamily:
+ 'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
+ },
+ },
+};
diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts
new file mode 100644
index 0000000..40f32ac
--- /dev/null
+++ b/src/config/definitions/domain-registry.test.ts
@@ -0,0 +1,59 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ CONFIG_OPTION_REGISTRY,
+ CONFIG_TEMPLATE_SECTIONS,
+ DEFAULT_CONFIG,
+ RUNTIME_OPTION_REGISTRY,
+} from '../definitions';
+import { buildCoreConfigOptionRegistry } from './options-core';
+import { buildImmersionConfigOptionRegistry } from './options-immersion';
+import { buildIntegrationConfigOptionRegistry } from './options-integrations';
+import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
+
+test('config option registry includes critical paths and has unique entries', () => {
+ const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
+
+ for (const requiredPath of [
+ 'logging.level',
+ 'subtitleStyle.enableJlpt',
+ 'ankiConnect.enabled',
+ 'immersionTracking.enabled',
+ ]) {
+ assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
+ }
+
+ assert.equal(new Set(paths).size, paths.length);
+});
+
+test('config template sections include expected domains and unique keys', () => {
+ const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
+ const requiredKeys: (typeof keys)[number][] = [
+ 'websocket',
+ 'subtitleStyle',
+ 'ankiConnect',
+ 'immersionTracking',
+ ];
+
+ for (const requiredKey of requiredKeys) {
+ assert.ok(keys.includes(requiredKey), `missing template section key: ${requiredKey}`);
+ }
+
+ assert.equal(new Set(keys).size, keys.length);
+});
+
+test('domain registry builders each contribute entries to composed registry', () => {
+ const domainEntries = [
+ buildCoreConfigOptionRegistry(DEFAULT_CONFIG),
+ buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
+ buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
+ buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
+ ];
+ const composedPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
+
+ for (const entries of domainEntries) {
+ assert.ok(entries.length > 0);
+ assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
+ }
+});
diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts
new file mode 100644
index 0000000..a2e44a1
--- /dev/null
+++ b/src/config/definitions/options-core.ts
@@ -0,0 +1,49 @@
+import { ResolvedConfig } from '../../types';
+import { ConfigOptionRegistryEntry } from './shared';
+
+export function buildCoreConfigOptionRegistry(
+ defaultConfig: ResolvedConfig,
+): ConfigOptionRegistryEntry[] {
+ return [
+ {
+ path: 'logging.level',
+ kind: 'enum',
+ enumValues: ['debug', 'info', 'warn', 'error'],
+ defaultValue: defaultConfig.logging.level,
+ description: 'Minimum log level for runtime logging.',
+ },
+ {
+ path: 'websocket.enabled',
+ kind: 'enum',
+ enumValues: ['auto', 'true', 'false'],
+ defaultValue: defaultConfig.websocket.enabled,
+ description: 'Built-in subtitle websocket server mode.',
+ },
+ {
+ path: 'websocket.port',
+ kind: 'number',
+ defaultValue: defaultConfig.websocket.port,
+ description: 'Built-in subtitle websocket server port.',
+ },
+ {
+ path: 'subsync.defaultMode',
+ kind: 'enum',
+ enumValues: ['auto', 'manual'],
+ defaultValue: defaultConfig.subsync.defaultMode,
+ description: 'Subsync default mode.',
+ },
+ {
+ path: 'shortcuts.multiCopyTimeoutMs',
+ kind: 'number',
+ defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs,
+ description: 'Timeout for multi-copy/mine modes.',
+ },
+ {
+ path: 'bind_visible_overlay_to_mpv_sub_visibility',
+ kind: 'boolean',
+ defaultValue: defaultConfig.bind_visible_overlay_to_mpv_sub_visibility,
+ description:
+ 'Link visible overlay toggles to MPV subtitle visibility (primary and secondary).',
+ },
+ ];
+}
diff --git a/src/config/definitions/options-immersion.ts b/src/config/definitions/options-immersion.ts
new file mode 100644
index 0000000..ccd6a99
--- /dev/null
+++ b/src/config/definitions/options-immersion.ts
@@ -0,0 +1,82 @@
+import { ResolvedConfig } from '../../types';
+import { ConfigOptionRegistryEntry } from './shared';
+
+export function buildImmersionConfigOptionRegistry(
+ defaultConfig: ResolvedConfig,
+): ConfigOptionRegistryEntry[] {
+ return [
+ {
+ path: 'immersionTracking.enabled',
+ kind: 'boolean',
+ defaultValue: defaultConfig.immersionTracking.enabled,
+ description: 'Enable immersion tracking for mined subtitle metadata.',
+ },
+ {
+ path: 'immersionTracking.dbPath',
+ kind: 'string',
+ defaultValue: defaultConfig.immersionTracking.dbPath,
+ description:
+ 'Optional SQLite database path for immersion tracking. Empty value uses the default app data path.',
+ },
+ {
+ path: 'immersionTracking.batchSize',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.batchSize,
+ description: 'Buffered telemetry/event writes per SQLite transaction.',
+ },
+ {
+ path: 'immersionTracking.flushIntervalMs',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.flushIntervalMs,
+ description: 'Max delay before queue flush in milliseconds.',
+ },
+ {
+ path: 'immersionTracking.queueCap',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.queueCap,
+ description: 'In-memory write queue cap before overflow policy applies.',
+ },
+ {
+ path: 'immersionTracking.payloadCapBytes',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.payloadCapBytes,
+ description: 'Max JSON payload size per event before truncation.',
+ },
+ {
+ path: 'immersionTracking.maintenanceIntervalMs',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.maintenanceIntervalMs,
+ description: 'Maintenance cadence (prune + rollup + vacuum checks).',
+ },
+ {
+ path: 'immersionTracking.retention.eventsDays',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.retention.eventsDays,
+ description: 'Raw event retention window in days.',
+ },
+ {
+ path: 'immersionTracking.retention.telemetryDays',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.retention.telemetryDays,
+ description: 'Telemetry retention window in days.',
+ },
+ {
+ path: 'immersionTracking.retention.dailyRollupsDays',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.retention.dailyRollupsDays,
+ description: 'Daily rollup retention window in days.',
+ },
+ {
+ path: 'immersionTracking.retention.monthlyRollupsDays',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.retention.monthlyRollupsDays,
+ description: 'Monthly rollup retention window in days.',
+ },
+ {
+ path: 'immersionTracking.retention.vacuumIntervalDays',
+ kind: 'number',
+ defaultValue: defaultConfig.immersionTracking.retention.vacuumIntervalDays,
+ description: 'Minimum days between VACUUM runs.',
+ },
+ ];
+}
diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts
new file mode 100644
index 0000000..f102207
--- /dev/null
+++ b/src/config/definitions/options-integrations.ts
@@ -0,0 +1,235 @@
+import { ResolvedConfig } from '../../types';
+import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
+
+export function buildIntegrationConfigOptionRegistry(
+ defaultConfig: ResolvedConfig,
+ runtimeOptionRegistry: RuntimeOptionRegistryEntry[],
+): ConfigOptionRegistryEntry[] {
+ return [
+ {
+ path: 'ankiConnect.enabled',
+ kind: 'boolean',
+ defaultValue: defaultConfig.ankiConnect.enabled,
+ description: 'Enable AnkiConnect integration.',
+ },
+ {
+ path: 'ankiConnect.pollingRate',
+ kind: 'number',
+ defaultValue: defaultConfig.ankiConnect.pollingRate,
+ description: 'Polling interval in milliseconds.',
+ },
+ {
+ path: 'ankiConnect.tags',
+ kind: 'array',
+ defaultValue: defaultConfig.ankiConnect.tags,
+ description:
+ 'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
+ },
+ {
+ path: 'ankiConnect.behavior.autoUpdateNewCards',
+ kind: 'boolean',
+ defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
+ description: 'Automatically update newly added cards.',
+ runtime: runtimeOptionRegistry[0],
+ },
+ {
+ path: 'ankiConnect.nPlusOne.matchMode',
+ kind: 'enum',
+ enumValues: ['headword', 'surface'],
+ defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode,
+ description: 'Known-word matching strategy for N+1 highlighting.',
+ },
+ {
+ path: 'ankiConnect.nPlusOne.highlightEnabled',
+ kind: 'boolean',
+ defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled,
+ description: 'Enable fast local highlighting for words already known in Anki.',
+ },
+ {
+ path: 'ankiConnect.nPlusOne.refreshMinutes',
+ kind: 'number',
+ defaultValue: defaultConfig.ankiConnect.nPlusOne.refreshMinutes,
+ description: 'Minutes between known-word cache refreshes.',
+ },
+ {
+ path: 'ankiConnect.nPlusOne.minSentenceWords',
+ kind: 'number',
+ defaultValue: defaultConfig.ankiConnect.nPlusOne.minSentenceWords,
+ description: 'Minimum sentence word count required for N+1 targeting (default: 3).',
+ },
+ {
+ path: 'ankiConnect.nPlusOne.decks',
+ kind: 'array',
+ defaultValue: defaultConfig.ankiConnect.nPlusOne.decks,
+ description: 'Decks used for N+1 known-word cache scope. Supports one or more deck names.',
+ },
+ {
+ path: 'ankiConnect.nPlusOne.nPlusOne',
+ kind: 'string',
+ defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne,
+ description: 'Color used for the single N+1 target token highlight.',
+ },
+ {
+ path: 'ankiConnect.nPlusOne.knownWord',
+ kind: 'string',
+ defaultValue: defaultConfig.ankiConnect.nPlusOne.knownWord,
+ description: 'Color used for legacy known-word highlights.',
+ },
+ {
+ path: 'ankiConnect.isKiku.fieldGrouping',
+ kind: 'enum',
+ enumValues: ['auto', 'manual', 'disabled'],
+ defaultValue: defaultConfig.ankiConnect.isKiku.fieldGrouping,
+ description: 'Kiku duplicate-card field grouping mode.',
+ runtime: runtimeOptionRegistry[1],
+ },
+ {
+ path: 'jimaku.languagePreference',
+ kind: 'enum',
+ enumValues: ['ja', 'en', 'none'],
+ defaultValue: defaultConfig.jimaku.languagePreference,
+ description: 'Preferred language used in Jimaku search.',
+ },
+ {
+ path: 'jimaku.maxEntryResults',
+ kind: 'number',
+ defaultValue: defaultConfig.jimaku.maxEntryResults,
+ description: 'Maximum Jimaku search results returned.',
+ },
+ {
+ path: 'anilist.enabled',
+ kind: 'boolean',
+ defaultValue: defaultConfig.anilist.enabled,
+ description: 'Enable AniList post-watch progress updates.',
+ },
+ {
+ path: 'anilist.accessToken',
+ kind: 'string',
+ defaultValue: defaultConfig.anilist.accessToken,
+ description:
+ 'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
+ },
+ {
+ path: 'jellyfin.enabled',
+ kind: 'boolean',
+ defaultValue: defaultConfig.jellyfin.enabled,
+ description: 'Enable optional Jellyfin integration and CLI control commands.',
+ },
+ {
+ path: 'jellyfin.serverUrl',
+ kind: 'string',
+ defaultValue: defaultConfig.jellyfin.serverUrl,
+ description: 'Base Jellyfin server URL (for example: http://localhost:8096).',
+ },
+ {
+ path: 'jellyfin.username',
+ kind: 'string',
+ defaultValue: defaultConfig.jellyfin.username,
+ description: 'Default Jellyfin username used during CLI login.',
+ },
+ {
+ path: 'jellyfin.defaultLibraryId',
+ kind: 'string',
+ defaultValue: defaultConfig.jellyfin.defaultLibraryId,
+ description: 'Optional default Jellyfin library ID for item listing.',
+ },
+ {
+ path: 'jellyfin.remoteControlEnabled',
+ kind: 'boolean',
+ defaultValue: defaultConfig.jellyfin.remoteControlEnabled,
+ description: 'Enable Jellyfin remote cast control mode.',
+ },
+ {
+ path: 'jellyfin.remoteControlAutoConnect',
+ kind: 'boolean',
+ defaultValue: defaultConfig.jellyfin.remoteControlAutoConnect,
+ description: 'Auto-connect to the configured remote control target.',
+ },
+ {
+ path: 'jellyfin.autoAnnounce',
+ kind: 'boolean',
+ defaultValue: defaultConfig.jellyfin.autoAnnounce,
+ description:
+ 'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
+ },
+ {
+ path: 'jellyfin.remoteControlDeviceName',
+ kind: 'string',
+ defaultValue: defaultConfig.jellyfin.remoteControlDeviceName,
+ description: 'Device name reported for Jellyfin remote control sessions.',
+ },
+ {
+ path: 'jellyfin.pullPictures',
+ kind: 'boolean',
+ defaultValue: defaultConfig.jellyfin.pullPictures,
+ description: 'Enable Jellyfin poster/icon fetching for launcher menus.',
+ },
+ {
+ path: 'jellyfin.iconCacheDir',
+ kind: 'string',
+ defaultValue: defaultConfig.jellyfin.iconCacheDir,
+ description: 'Directory used by launcher for cached Jellyfin poster icons.',
+ },
+ {
+ path: 'jellyfin.directPlayPreferred',
+ kind: 'boolean',
+ defaultValue: defaultConfig.jellyfin.directPlayPreferred,
+ description: 'Try direct play before server-managed transcoding when possible.',
+ },
+ {
+ path: 'jellyfin.directPlayContainers',
+ kind: 'array',
+ defaultValue: defaultConfig.jellyfin.directPlayContainers,
+ description: 'Container allowlist for direct play decisions.',
+ },
+ {
+ path: 'jellyfin.transcodeVideoCodec',
+ kind: 'string',
+ defaultValue: defaultConfig.jellyfin.transcodeVideoCodec,
+ description: 'Preferred transcode video codec when direct play is unavailable.',
+ },
+ {
+ path: 'discordPresence.enabled',
+ kind: 'boolean',
+ defaultValue: defaultConfig.discordPresence.enabled,
+ description: 'Enable optional Discord Rich Presence updates.',
+ },
+ {
+ path: 'discordPresence.updateIntervalMs',
+ kind: 'number',
+ defaultValue: defaultConfig.discordPresence.updateIntervalMs,
+ description: 'Minimum interval between presence payload updates.',
+ },
+ {
+ path: 'discordPresence.debounceMs',
+ kind: 'number',
+ defaultValue: defaultConfig.discordPresence.debounceMs,
+ description: 'Debounce delay used to collapse bursty presence updates.',
+ },
+ {
+ path: 'youtubeSubgen.mode',
+ kind: 'enum',
+ enumValues: ['automatic', 'preprocess', 'off'],
+ defaultValue: defaultConfig.youtubeSubgen.mode,
+ description: 'YouTube subtitle generation mode for the launcher script.',
+ },
+ {
+ path: 'youtubeSubgen.whisperBin',
+ kind: 'string',
+ defaultValue: defaultConfig.youtubeSubgen.whisperBin,
+ description: 'Path to whisper.cpp CLI used as fallback transcription engine.',
+ },
+ {
+ path: 'youtubeSubgen.whisperModel',
+ kind: 'string',
+ defaultValue: defaultConfig.youtubeSubgen.whisperModel,
+ description: 'Path to whisper model used for fallback transcription.',
+ },
+ {
+ path: 'youtubeSubgen.primarySubLanguages',
+ kind: 'string',
+ defaultValue: defaultConfig.youtubeSubgen.primarySubLanguages.join(','),
+ description: 'Comma-separated primary subtitle language priority used by the launcher.',
+ },
+ ];
+}
diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts
new file mode 100644
index 0000000..e7d5bf7
--- /dev/null
+++ b/src/config/definitions/options-subtitle.ts
@@ -0,0 +1,72 @@
+import { ResolvedConfig } from '../../types';
+import { ConfigOptionRegistryEntry } from './shared';
+
+export function buildSubtitleConfigOptionRegistry(
+ defaultConfig: ResolvedConfig,
+): ConfigOptionRegistryEntry[] {
+ return [
+ {
+ path: 'subtitleStyle.enableJlpt',
+ kind: 'boolean',
+ defaultValue: defaultConfig.subtitleStyle.enableJlpt,
+ description:
+ 'Enable JLPT vocabulary level underlines. ' +
+ 'When disabled, JLPT tagging lookup and underlines are skipped.',
+ },
+ {
+ path: 'subtitleStyle.preserveLineBreaks',
+ kind: 'boolean',
+ defaultValue: defaultConfig.subtitleStyle.preserveLineBreaks,
+ description:
+ 'Preserve line breaks in visible overlay subtitle rendering. ' +
+ 'When false, line breaks are flattened to spaces for a single-line flow.',
+ },
+ {
+ path: 'subtitleStyle.hoverTokenColor',
+ kind: 'string',
+ defaultValue: defaultConfig.subtitleStyle.hoverTokenColor,
+ description: 'Hex color used for hovered subtitle token highlight in mpv.',
+ },
+ {
+ path: 'subtitleStyle.frequencyDictionary.enabled',
+ kind: 'boolean',
+ defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.enabled,
+ description: 'Enable frequency-dictionary-based highlighting based on token rank.',
+ },
+ {
+ path: 'subtitleStyle.frequencyDictionary.sourcePath',
+ kind: 'string',
+ defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.sourcePath,
+ description:
+ 'Optional absolute path to a frequency dictionary directory.' +
+ ' If empty, built-in discovery search paths are used.',
+ },
+ {
+ path: 'subtitleStyle.frequencyDictionary.topX',
+ kind: 'number',
+ defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.topX,
+ description: 'Only color tokens with frequency rank <= topX (default: 1000).',
+ },
+ {
+ path: 'subtitleStyle.frequencyDictionary.mode',
+ kind: 'enum',
+ enumValues: ['single', 'banded'],
+ defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.mode,
+ description:
+ 'single: use one color for all matching tokens. banded: use color ramp by frequency band.',
+ },
+ {
+ path: 'subtitleStyle.frequencyDictionary.singleColor',
+ kind: 'string',
+ defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.singleColor,
+ description: 'Color used when frequencyDictionary.mode is `single`.',
+ },
+ {
+ path: 'subtitleStyle.frequencyDictionary.bandedColors',
+ kind: 'array',
+ defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.bandedColors,
+ description:
+ 'Five colors used for rank bands when mode is `banded` (from most common to least within topX).',
+ },
+ ];
+}
diff --git a/src/config/definitions/runtime-options.ts b/src/config/definitions/runtime-options.ts
new file mode 100644
index 0000000..e35dade
--- /dev/null
+++ b/src/config/definitions/runtime-options.ts
@@ -0,0 +1,56 @@
+import { ResolvedConfig } from '../../types';
+import { RuntimeOptionRegistryEntry } from './shared';
+
+export function buildRuntimeOptionRegistry(
+ defaultConfig: ResolvedConfig,
+): RuntimeOptionRegistryEntry[] {
+ return [
+ {
+ id: 'anki.autoUpdateNewCards',
+ path: 'ankiConnect.behavior.autoUpdateNewCards',
+ label: 'Auto Update New Cards',
+ scope: 'ankiConnect',
+ valueType: 'boolean',
+ allowedValues: [true, false],
+ defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
+ requiresRestart: false,
+ formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
+ toAnkiPatch: (value) => ({
+ behavior: { autoUpdateNewCards: value === true },
+ }),
+ },
+ {
+ id: 'anki.nPlusOneMatchMode',
+ path: 'ankiConnect.nPlusOne.matchMode',
+ label: 'N+1 Match Mode',
+ scope: 'ankiConnect',
+ valueType: 'enum',
+ allowedValues: ['headword', 'surface'],
+ defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode,
+ requiresRestart: false,
+ formatValueForOsd: (value) => String(value),
+ toAnkiPatch: (value) => ({
+ nPlusOne: {
+ matchMode: value === 'headword' || value === 'surface' ? value : 'headword',
+ },
+ }),
+ },
+ {
+ id: 'anki.kikuFieldGrouping',
+ path: 'ankiConnect.isKiku.fieldGrouping',
+ label: 'Kiku Field Grouping',
+ scope: 'ankiConnect',
+ valueType: 'enum',
+ allowedValues: ['auto', 'manual', 'disabled'],
+ defaultValue: 'disabled',
+ requiresRestart: false,
+ formatValueForOsd: (value) => String(value),
+ toAnkiPatch: (value) => ({
+ isKiku: {
+ fieldGrouping:
+ value === 'auto' || value === 'manual' || value === 'disabled' ? value : 'disabled',
+ },
+ }),
+ },
+ ];
+}
diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts
new file mode 100644
index 0000000..ad648a5
--- /dev/null
+++ b/src/config/definitions/shared.ts
@@ -0,0 +1,61 @@
+import {
+ AnkiConnectConfig,
+ ResolvedConfig,
+ RuntimeOptionId,
+ RuntimeOptionScope,
+ RuntimeOptionValue,
+ RuntimeOptionValueType,
+} from '../../types';
+
+export type ConfigValueKind = 'boolean' | 'number' | 'string' | 'enum' | 'array' | 'object';
+
+export interface RuntimeOptionRegistryEntry {
+ id: RuntimeOptionId;
+ path: string;
+ label: string;
+ scope: RuntimeOptionScope;
+ valueType: RuntimeOptionValueType;
+ allowedValues: RuntimeOptionValue[];
+ defaultValue: RuntimeOptionValue;
+ requiresRestart: boolean;
+ formatValueForOsd: (value: RuntimeOptionValue) => string;
+ toAnkiPatch: (value: RuntimeOptionValue) => Partial;
+}
+
+export interface ConfigOptionRegistryEntry {
+ path: string;
+ kind: ConfigValueKind;
+ defaultValue: unknown;
+ description: string;
+ enumValues?: readonly string[];
+ runtime?: RuntimeOptionRegistryEntry;
+}
+
+export interface ConfigTemplateSection {
+ title: string;
+ description: string[];
+ key: keyof ResolvedConfig;
+ notes?: string[];
+}
+
+export const SPECIAL_COMMANDS = {
+ SUBSYNC_TRIGGER: '__subsync-trigger',
+ RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
+ RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
+ REPLAY_SUBTITLE: '__replay-subtitle',
+ PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
+} as const;
+
+export const DEFAULT_KEYBINDINGS: NonNullable = [
+ { key: 'Space', command: ['cycle', 'pause'] },
+ { key: 'ArrowRight', command: ['seek', 5] },
+ { key: 'ArrowLeft', command: ['seek', -5] },
+ { key: 'ArrowUp', command: ['seek', 60] },
+ { key: 'ArrowDown', command: ['seek', -60] },
+ { key: 'Shift+KeyH', command: ['sub-seek', -1] },
+ { key: 'Shift+KeyL', command: ['sub-seek', 1] },
+ { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
+ { key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
+ { key: 'KeyQ', command: ['quit'] },
+ { key: 'Ctrl+KeyW', command: ['quit'] },
+];
diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts
new file mode 100644
index 0000000..a4a5a4f
--- /dev/null
+++ b/src/config/definitions/template-sections.ts
@@ -0,0 +1,154 @@
+import { ConfigTemplateSection } from './shared';
+
+const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
+ {
+ title: 'Overlay Auto-Start',
+ description: [
+ 'When overlay connects to mpv, automatically show overlay and hide mpv subtitles.',
+ ],
+ key: 'auto_start_overlay',
+ },
+ {
+ title: 'Visible Overlay Subtitle Binding',
+ description: [
+ 'Control whether visible overlay toggles also toggle MPV subtitle visibility.',
+ 'When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.',
+ ],
+ key: 'bind_visible_overlay_to_mpv_sub_visibility',
+ },
+ {
+ title: 'Texthooker Server',
+ description: ['Control whether browser opens automatically for texthooker.'],
+ key: 'texthooker',
+ },
+ {
+ title: 'WebSocket Server',
+ description: [
+ 'Built-in WebSocket server broadcasts subtitle text to connected clients.',
+ 'Auto mode disables built-in server if mpv_websocket is detected.',
+ ],
+ key: 'websocket',
+ },
+ {
+ title: 'Logging',
+ description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
+ key: 'logging',
+ },
+ {
+ title: 'Keyboard Shortcuts',
+ description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
+ notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
+ key: 'shortcuts',
+ },
+ {
+ title: 'Invisible Overlay',
+ description: ['Startup behavior for the invisible interactive subtitle mining layer.'],
+ notes: [
+ 'Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.',
+ 'This edit-mode shortcut is fixed and is not currently configurable.',
+ ],
+ key: 'invisibleOverlay',
+ },
+ {
+ title: 'Keybindings (MPV Commands)',
+ description: [
+ 'Extra keybindings that are merged with built-in defaults.',
+ 'Set command to null to disable a default keybinding.',
+ ],
+ notes: [
+ 'Hot-reload: keybinding changes apply live and update the session help modal on reopen.',
+ ],
+ key: 'keybindings',
+ },
+ {
+ title: 'Secondary Subtitles',
+ description: [
+ 'Dual subtitle track options.',
+ 'Used by subminer YouTube subtitle generation as secondary language preferences.',
+ ],
+ notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
+ key: 'secondarySub',
+ },
+ {
+ title: 'Auto Subtitle Sync',
+ description: ['Subsync engine and executable paths.'],
+ key: 'subsync',
+ },
+ {
+ title: 'Subtitle Position',
+ description: ['Initial vertical subtitle position from the bottom.'],
+ key: 'subtitlePosition',
+ },
+];
+
+const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
+ {
+ title: 'Subtitle Appearance',
+ description: ['Primary and secondary subtitle styling.'],
+ notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
+ key: 'subtitleStyle',
+ },
+];
+
+const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
+ {
+ title: 'AnkiConnect Integration',
+ description: ['Automatic Anki updates and media generation options.'],
+ notes: [
+ 'Hot-reload: AI translation settings update live while SubMiner is running.',
+ 'Most other AnkiConnect settings still require restart.',
+ ],
+ key: 'ankiConnect',
+ },
+ {
+ title: 'Jimaku',
+ description: ['Jimaku API configuration and defaults.'],
+ key: 'jimaku',
+ },
+ {
+ title: 'YouTube Subtitle Generation',
+ description: ['Defaults for subminer YouTube subtitle extraction/transcription mode.'],
+ key: 'youtubeSubgen',
+ },
+ {
+ title: 'Anilist',
+ description: ['Anilist API credentials and update behavior.'],
+ key: 'anilist',
+ },
+ {
+ title: 'Jellyfin',
+ description: [
+ 'Optional Jellyfin integration for auth, browsing, and playback launch.',
+ 'Access token is stored in local encrypted token storage after login/setup.',
+ 'jellyfin.accessToken remains an optional explicit override in config.',
+ ],
+ key: 'jellyfin',
+ },
+ {
+ title: 'Discord Rich Presence',
+ description: [
+ 'Optional Discord Rich Presence activity card updates for current playback/study session.',
+ 'Uses official SubMiner Discord app assets for polished card visuals.',
+ ],
+ key: 'discordPresence',
+ },
+];
+
+const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
+ {
+ title: 'Immersion Tracking',
+ description: [
+ 'Enable/disable immersion tracking.',
+ 'Set dbPath to override the default sqlite database location.',
+ 'Policy tuning is available for queue, flush, and retention values.',
+ ],
+ key: 'immersionTracking',
+ },
+];
+
+export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
+ ...CORE_TEMPLATE_SECTIONS,
+ ...SUBTITLE_TEMPLATE_SECTIONS,
+ ...INTEGRATION_TEMPLATE_SECTIONS,
+ ...IMMERSION_TEMPLATE_SECTIONS,
+];
diff --git a/src/config/index.ts b/src/config/index.ts
new file mode 100644
index 0000000..e8bf318
--- /dev/null
+++ b/src/config/index.ts
@@ -0,0 +1,3 @@
+export * from './definitions';
+export * from './service';
+export * from './template';
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/path-resolution.test.ts b/src/config/path-resolution.test.ts
new file mode 100644
index 0000000..abd06cd
--- /dev/null
+++ b/src/config/path-resolution.test.ts
@@ -0,0 +1,89 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import { resolveConfigBaseDirs, resolveConfigDir, resolveConfigFilePath } from './path-resolution';
+
+function existsSyncFrom(paths: string[]): (candidate: string) => boolean {
+ const normalized = new Set(paths.map((entry) => path.normalize(entry)));
+ return (candidate: string): boolean => normalized.has(path.normalize(candidate));
+}
+
+test('resolveConfigBaseDirs trims xdg value and deduplicates fallback dir', () => {
+ const homeDir = '/home/tester';
+ const baseDirs = resolveConfigBaseDirs(' /home/tester/.config ', homeDir);
+ assert.deepEqual(baseDirs, [path.join(homeDir, '.config')]);
+});
+
+test('resolveConfigDir prefers xdg SubMiner config when present', () => {
+ const homeDir = '/home/tester';
+ const xdgConfigHome = '/tmp/xdg-config';
+ const configDir = path.join(xdgConfigHome, 'SubMiner');
+ const existsSync = existsSyncFrom([path.join(configDir, 'config.jsonc')]);
+
+ const resolved = resolveConfigDir({
+ xdgConfigHome,
+ homeDir,
+ existsSync,
+ });
+
+ assert.equal(resolved, configDir);
+});
+
+test('resolveConfigDir ignores lowercase subminer candidate', () => {
+ const homeDir = '/home/tester';
+ const lowercaseConfigDir = path.join(homeDir, '.config', 'subminer');
+ const existsSync = existsSyncFrom([path.join(lowercaseConfigDir, 'config.json')]);
+
+ const resolved = resolveConfigDir({
+ xdgConfigHome: '/tmp/missing-xdg',
+ homeDir,
+ existsSync,
+ });
+
+ assert.equal(resolved, '/tmp/missing-xdg/SubMiner');
+});
+
+test('resolveConfigDir falls back to existing directory when file is missing', () => {
+ const homeDir = '/home/tester';
+ const configDir = path.join(homeDir, '.config', 'SubMiner');
+ const existsSync = existsSyncFrom([configDir]);
+
+ const resolved = resolveConfigDir({
+ xdgConfigHome: '/tmp/missing-xdg',
+ homeDir,
+ existsSync,
+ });
+
+ assert.equal(resolved, configDir);
+});
+
+test('resolveConfigFilePath prefers jsonc before json', () => {
+ const homeDir = '/home/tester';
+ const xdgConfigHome = '/tmp/xdg-config';
+ const existsSync = existsSyncFrom([
+ path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
+ path.join(xdgConfigHome, 'SubMiner', 'config.json'),
+ ]);
+
+ const resolved = resolveConfigFilePath({
+ xdgConfigHome,
+ homeDir,
+ existsSync,
+ });
+
+ assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
+});
+
+test('resolveConfigFilePath keeps legacy fallback output path', () => {
+ const homeDir = '/home/tester';
+ const xdgConfigHome = '/tmp/xdg-config';
+ const existsSync = existsSyncFrom([]);
+
+ const resolved = resolveConfigFilePath({
+ xdgConfigHome,
+ homeDir,
+ existsSync,
+ });
+
+ assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
+});
diff --git a/src/config/path-resolution.ts b/src/config/path-resolution.ts
new file mode 100644
index 0000000..ddd7469
--- /dev/null
+++ b/src/config/path-resolution.ts
@@ -0,0 +1,76 @@
+import path from 'node:path';
+
+type ExistsSync = (candidate: string) => boolean;
+
+type ConfigPathOptions = {
+ xdgConfigHome?: string;
+ homeDir: string;
+ existsSync: ExistsSync;
+ appNames?: readonly string[];
+ defaultAppName?: string;
+};
+
+const DEFAULT_APP_NAMES = ['SubMiner'] as const;
+const DEFAULT_FILE_NAMES = ['config.jsonc', 'config.json'] as const;
+
+export function resolveConfigBaseDirs(
+ xdgConfigHome: string | undefined,
+ homeDir: string,
+): string[] {
+ const fallbackBaseDir = path.join(homeDir, '.config');
+ const primaryBaseDir = xdgConfigHome?.trim() || fallbackBaseDir;
+ return Array.from(new Set([primaryBaseDir, fallbackBaseDir]));
+}
+
+function getAppNames(options: ConfigPathOptions): readonly string[] {
+ return options.appNames ?? DEFAULT_APP_NAMES;
+}
+
+function getDefaultAppName(options: ConfigPathOptions): string {
+ return options.defaultAppName ?? DEFAULT_APP_NAMES[0];
+}
+
+export function resolveConfigDir(options: ConfigPathOptions): string {
+ const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
+ const appNames = getAppNames(options);
+
+ for (const baseDir of baseDirs) {
+ for (const appName of appNames) {
+ const dir = path.join(baseDir, appName);
+ for (const fileName of DEFAULT_FILE_NAMES) {
+ if (options.existsSync(path.join(dir, fileName))) {
+ return dir;
+ }
+ }
+ }
+ }
+
+ for (const baseDir of baseDirs) {
+ for (const appName of appNames) {
+ const dir = path.join(baseDir, appName);
+ if (options.existsSync(dir)) {
+ return dir;
+ }
+ }
+ }
+
+ return path.join(baseDirs[0]!, getDefaultAppName(options));
+}
+
+export function resolveConfigFilePath(options: ConfigPathOptions): string {
+ const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
+ const appNames = getAppNames(options);
+
+ for (const baseDir of baseDirs) {
+ for (const appName of appNames) {
+ for (const fileName of DEFAULT_FILE_NAMES) {
+ const candidate = path.join(baseDir, appName, fileName);
+ if (options.existsSync(candidate)) {
+ return candidate;
+ }
+ }
+ }
+ }
+
+ return path.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!);
+}
diff --git a/src/config/resolve.ts b/src/config/resolve.ts
new file mode 100644
index 0000000..d8eed5a
--- /dev/null
+++ b/src/config/resolve.ts
@@ -0,0 +1,33 @@
+import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
+import { applyAnkiConnectResolution } from './resolve/anki-connect';
+import { createResolveContext } from './resolve/context';
+import { applyCoreDomainConfig } from './resolve/core-domains';
+import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
+import { applyIntegrationConfig } from './resolve/integrations';
+import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
+import { applyTopLevelConfig } from './resolve/top-level';
+
+const APPLY_RESOLVE_STEPS = [
+ applyTopLevelConfig,
+ applyCoreDomainConfig,
+ applySubtitleDomainConfig,
+ applyIntegrationConfig,
+ applyImmersionTrackingConfig,
+ applyAnkiConnectResolution,
+] as const;
+
+export function resolveConfig(raw: RawConfig): {
+ resolved: ResolvedConfig;
+ warnings: ConfigValidationWarning[];
+} {
+ const { context, warnings } = createResolveContext(raw);
+
+ for (const applyStep of APPLY_RESOLVE_STEPS) {
+ applyStep(context);
+ }
+
+ return {
+ resolved: context.resolved,
+ warnings,
+ };
+}
diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts
new file mode 100644
index 0000000..0b7a1cd
--- /dev/null
+++ b/src/config/resolve/anki-connect.test.ts
@@ -0,0 +1,68 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions';
+import { createWarningCollector } from '../warnings';
+import { applyAnkiConnectResolution } from './anki-connect';
+import type { ResolveContext } from './context';
+
+function makeContext(ankiConnect: unknown): {
+ context: ResolveContext;
+ warnings: ReturnType['warnings'];
+} {
+ const { warnings, warn } = createWarningCollector();
+ const resolved = deepCloneConfig(DEFAULT_CONFIG);
+ const context = {
+ src: { ankiConnect },
+ resolved,
+ warn,
+ } as unknown as ResolveContext;
+
+ return { context, warnings };
+}
+
+test('modern invalid nPlusOne.highlightEnabled warns modern key and does not fallback to legacy', () => {
+ const { context, warnings } = makeContext({
+ behavior: { nPlusOneHighlightEnabled: true },
+ nPlusOne: { highlightEnabled: 'yes' },
+ });
+
+ applyAnkiConnectResolution(context);
+
+ assert.equal(
+ context.resolved.ankiConnect.nPlusOne.highlightEnabled,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
+ );
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'));
+ assert.equal(
+ warnings.some((warning) => warning.path === 'ankiConnect.behavior.nPlusOneHighlightEnabled'),
+ false,
+ );
+});
+
+test('normalizes ankiConnect tags by trimming and deduping', () => {
+ const { context, warnings } = makeContext({
+ tags: [' SubMiner ', 'Mining', 'SubMiner', ' Mining '],
+ });
+
+ applyAnkiConnectResolution(context);
+
+ assert.deepEqual(context.resolved.ankiConnect.tags, ['SubMiner', 'Mining']);
+ assert.equal(
+ warnings.some((warning) => warning.path === 'ankiConnect.tags'),
+ false,
+ );
+});
+
+test('warns and falls back for invalid nPlusOne.decks entries', () => {
+ const { context, warnings } = makeContext({
+ nPlusOne: { decks: ['Core Deck', 123] },
+ });
+
+ applyAnkiConnectResolution(context);
+
+ assert.deepEqual(
+ context.resolved.ankiConnect.nPlusOne.decks,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.decks,
+ );
+ assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks'));
+});
diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts
new file mode 100644
index 0000000..f88d7e6
--- /dev/null
+++ b/src/config/resolve/anki-connect.ts
@@ -0,0 +1,728 @@
+import { DEFAULT_CONFIG } from '../definitions';
+import type { ResolveContext } from './context';
+import { asBoolean, asColor, asNumber, asString, isObject } from './shared';
+
+export function applyAnkiConnectResolution(context: ResolveContext): void {
+ if (!isObject(context.src.ankiConnect)) {
+ return;
+ }
+
+ const ac = context.src.ankiConnect;
+ const behavior = isObject(ac.behavior) ? (ac.behavior as Record) : {};
+ const fields = isObject(ac.fields) ? (ac.fields as Record) : {};
+ const media = isObject(ac.media) ? (ac.media as Record) : {};
+ const metadata = isObject(ac.metadata) ? (ac.metadata as Record) : {};
+ const 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) {
+ context.warn(
+ 'ankiConnect.openRouter',
+ ac.openRouter,
+ context.resolved.ankiConnect.ai,
+ 'Deprecated key; use ankiConnect.ai instead.',
+ );
+ }
+
+ const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } = ac as Record<
+ string,
+ unknown
+ >;
+ const ankiConnectWithoutLegacy = Object.fromEntries(
+ Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)),
+ );
+
+ context.resolved.ankiConnect = {
+ ...context.resolved.ankiConnect,
+ ...(isObject(ankiConnectWithoutLegacy)
+ ? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>)
+ : {}),
+ fields: {
+ ...context.resolved.ankiConnect.fields,
+ ...(isObject(ac.fields)
+ ? (ac.fields as (typeof context.resolved)['ankiConnect']['fields'])
+ : {}),
+ },
+ ai: {
+ ...context.resolved.ankiConnect.ai,
+ ...(aiSource as (typeof context.resolved)['ankiConnect']['ai']),
+ },
+ media: {
+ ...context.resolved.ankiConnect.media,
+ ...(isObject(ac.media)
+ ? (ac.media as (typeof context.resolved)['ankiConnect']['media'])
+ : {}),
+ },
+ behavior: {
+ ...context.resolved.ankiConnect.behavior,
+ ...(isObject(ac.behavior)
+ ? (ac.behavior as (typeof context.resolved)['ankiConnect']['behavior'])
+ : {}),
+ },
+ metadata: {
+ ...context.resolved.ankiConnect.metadata,
+ ...(isObject(ac.metadata)
+ ? (ac.metadata as (typeof context.resolved)['ankiConnect']['metadata'])
+ : {}),
+ },
+ isLapis: {
+ ...context.resolved.ankiConnect.isLapis,
+ },
+ isKiku: {
+ ...context.resolved.ankiConnect.isKiku,
+ ...(isObject(ac.isKiku)
+ ? (ac.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
+ : {}),
+ },
+ };
+
+ if (isObject(ac.isLapis)) {
+ const lapisEnabled = asBoolean(ac.isLapis.enabled);
+ if (lapisEnabled !== undefined) {
+ context.resolved.ankiConnect.isLapis.enabled = lapisEnabled;
+ } else if (ac.isLapis.enabled !== undefined) {
+ context.warn(
+ 'ankiConnect.isLapis.enabled',
+ ac.isLapis.enabled,
+ context.resolved.ankiConnect.isLapis.enabled,
+ 'Expected boolean.',
+ );
+ }
+
+ const sentenceCardModel = asString(ac.isLapis.sentenceCardModel);
+ if (sentenceCardModel !== undefined) {
+ context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel;
+ } else if (ac.isLapis.sentenceCardModel !== undefined) {
+ context.warn(
+ 'ankiConnect.isLapis.sentenceCardModel',
+ ac.isLapis.sentenceCardModel,
+ context.resolved.ankiConnect.isLapis.sentenceCardModel,
+ 'Expected string.',
+ );
+ }
+
+ if (ac.isLapis.sentenceCardSentenceField !== undefined) {
+ context.warn(
+ 'ankiConnect.isLapis.sentenceCardSentenceField',
+ ac.isLapis.sentenceCardSentenceField,
+ 'Sentence',
+ 'Deprecated key; sentence-card sentence field is fixed to Sentence.',
+ );
+ }
+
+ if (ac.isLapis.sentenceCardAudioField !== undefined) {
+ context.warn(
+ 'ankiConnect.isLapis.sentenceCardAudioField',
+ ac.isLapis.sentenceCardAudioField,
+ 'SentenceAudio',
+ 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.',
+ );
+ }
+ } else if (ac.isLapis !== undefined) {
+ context.warn(
+ 'ankiConnect.isLapis',
+ ac.isLapis,
+ context.resolved.ankiConnect.isLapis,
+ 'Expected object.',
+ );
+ }
+
+ if (Array.isArray(ac.tags)) {
+ const normalizedTags = ac.tags
+ .filter((entry): entry is string => typeof entry === 'string')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+ if (normalizedTags.length === ac.tags.length) {
+ context.resolved.ankiConnect.tags = [...new Set(normalizedTags)];
+ } else {
+ context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
+ context.warn(
+ 'ankiConnect.tags',
+ ac.tags,
+ context.resolved.ankiConnect.tags,
+ 'Expected an array of non-empty strings.',
+ );
+ }
+ } else if (ac.tags !== undefined) {
+ context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
+ context.warn(
+ 'ankiConnect.tags',
+ ac.tags,
+ context.resolved.ankiConnect.tags,
+ 'Expected an array of strings.',
+ );
+ }
+
+ const legacy = ac as Record;
+ const 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) {
+ context.warn(`ankiConnect.${key}`, value, fallback, message);
+ return;
+ }
+ apply(parsed);
+ };
+
+ if (!hasOwn(fields, 'audio')) {
+ mapLegacy(
+ 'audioField',
+ asString,
+ (value) => {
+ context.resolved.ankiConnect.fields.audio = value;
+ },
+ context.resolved.ankiConnect.fields.audio,
+ 'Expected string.',
+ );
+ }
+ if (!hasOwn(fields, 'image')) {
+ mapLegacy(
+ 'imageField',
+ asString,
+ (value) => {
+ context.resolved.ankiConnect.fields.image = value;
+ },
+ context.resolved.ankiConnect.fields.image,
+ 'Expected string.',
+ );
+ }
+ if (!hasOwn(fields, 'sentence')) {
+ mapLegacy(
+ 'sentenceField',
+ asString,
+ (value) => {
+ context.resolved.ankiConnect.fields.sentence = value;
+ },
+ context.resolved.ankiConnect.fields.sentence,
+ 'Expected string.',
+ );
+ }
+ if (!hasOwn(fields, 'miscInfo')) {
+ mapLegacy(
+ 'miscInfoField',
+ asString,
+ (value) => {
+ context.resolved.ankiConnect.fields.miscInfo = value;
+ },
+ context.resolved.ankiConnect.fields.miscInfo,
+ 'Expected string.',
+ );
+ }
+ if (!hasOwn(metadata, 'pattern')) {
+ mapLegacy(
+ 'miscInfoPattern',
+ asString,
+ (value) => {
+ context.resolved.ankiConnect.metadata.pattern = value;
+ },
+ context.resolved.ankiConnect.metadata.pattern,
+ 'Expected string.',
+ );
+ }
+ if (!hasOwn(media, 'generateAudio')) {
+ mapLegacy(
+ 'generateAudio',
+ asBoolean,
+ (value) => {
+ context.resolved.ankiConnect.media.generateAudio = value;
+ },
+ context.resolved.ankiConnect.media.generateAudio,
+ 'Expected boolean.',
+ );
+ }
+ if (!hasOwn(media, 'generateImage')) {
+ mapLegacy(
+ 'generateImage',
+ asBoolean,
+ (value) => {
+ context.resolved.ankiConnect.media.generateImage = value;
+ },
+ context.resolved.ankiConnect.media.generateImage,
+ 'Expected boolean.',
+ );
+ }
+ if (!hasOwn(media, 'imageType')) {
+ mapLegacy(
+ 'imageType',
+ asImageType,
+ (value) => {
+ context.resolved.ankiConnect.media.imageType = value;
+ },
+ context.resolved.ankiConnect.media.imageType,
+ "Expected 'static' or 'avif'.",
+ );
+ }
+ if (!hasOwn(media, 'imageFormat')) {
+ mapLegacy(
+ 'imageFormat',
+ asImageFormat,
+ (value) => {
+ context.resolved.ankiConnect.media.imageFormat = value;
+ },
+ context.resolved.ankiConnect.media.imageFormat,
+ "Expected 'jpg', 'png', or 'webp'.",
+ );
+ }
+ if (!hasOwn(media, 'imageQuality')) {
+ mapLegacy(
+ 'imageQuality',
+ (value) => asIntegerInRange(value, 1, 100),
+ (value) => {
+ context.resolved.ankiConnect.media.imageQuality = value;
+ },
+ context.resolved.ankiConnect.media.imageQuality,
+ 'Expected integer between 1 and 100.',
+ );
+ }
+ if (!hasOwn(media, 'imageMaxWidth')) {
+ mapLegacy(
+ 'imageMaxWidth',
+ asPositiveInteger,
+ (value) => {
+ context.resolved.ankiConnect.media.imageMaxWidth = value;
+ },
+ context.resolved.ankiConnect.media.imageMaxWidth,
+ 'Expected positive integer.',
+ );
+ }
+ if (!hasOwn(media, 'imageMaxHeight')) {
+ mapLegacy(
+ 'imageMaxHeight',
+ asPositiveInteger,
+ (value) => {
+ context.resolved.ankiConnect.media.imageMaxHeight = value;
+ },
+ context.resolved.ankiConnect.media.imageMaxHeight,
+ 'Expected positive integer.',
+ );
+ }
+ if (!hasOwn(media, 'animatedFps')) {
+ mapLegacy(
+ 'animatedFps',
+ (value) => asIntegerInRange(value, 1, 60),
+ (value) => {
+ context.resolved.ankiConnect.media.animatedFps = value;
+ },
+ context.resolved.ankiConnect.media.animatedFps,
+ 'Expected integer between 1 and 60.',
+ );
+ }
+ if (!hasOwn(media, 'animatedMaxWidth')) {
+ mapLegacy(
+ 'animatedMaxWidth',
+ asPositiveInteger,
+ (value) => {
+ context.resolved.ankiConnect.media.animatedMaxWidth = value;
+ },
+ context.resolved.ankiConnect.media.animatedMaxWidth,
+ 'Expected positive integer.',
+ );
+ }
+ if (!hasOwn(media, 'animatedMaxHeight')) {
+ mapLegacy(
+ 'animatedMaxHeight',
+ asPositiveInteger,
+ (value) => {
+ context.resolved.ankiConnect.media.animatedMaxHeight = value;
+ },
+ context.resolved.ankiConnect.media.animatedMaxHeight,
+ 'Expected positive integer.',
+ );
+ }
+ if (!hasOwn(media, 'animatedCrf')) {
+ mapLegacy(
+ 'animatedCrf',
+ (value) => asIntegerInRange(value, 0, 63),
+ (value) => {
+ context.resolved.ankiConnect.media.animatedCrf = value;
+ },
+ context.resolved.ankiConnect.media.animatedCrf,
+ 'Expected integer between 0 and 63.',
+ );
+ }
+ if (!hasOwn(media, 'audioPadding')) {
+ mapLegacy(
+ 'audioPadding',
+ asNonNegativeNumber,
+ (value) => {
+ context.resolved.ankiConnect.media.audioPadding = value;
+ },
+ context.resolved.ankiConnect.media.audioPadding,
+ 'Expected non-negative number.',
+ );
+ }
+ if (!hasOwn(media, 'fallbackDuration')) {
+ mapLegacy(
+ 'fallbackDuration',
+ asPositiveNumber,
+ (value) => {
+ context.resolved.ankiConnect.media.fallbackDuration = value;
+ },
+ context.resolved.ankiConnect.media.fallbackDuration,
+ 'Expected positive number.',
+ );
+ }
+ if (!hasOwn(media, 'maxMediaDuration')) {
+ mapLegacy(
+ 'maxMediaDuration',
+ asNonNegativeNumber,
+ (value) => {
+ context.resolved.ankiConnect.media.maxMediaDuration = value;
+ },
+ context.resolved.ankiConnect.media.maxMediaDuration,
+ 'Expected non-negative number.',
+ );
+ }
+ if (!hasOwn(behavior, 'overwriteAudio')) {
+ mapLegacy(
+ 'overwriteAudio',
+ asBoolean,
+ (value) => {
+ context.resolved.ankiConnect.behavior.overwriteAudio = value;
+ },
+ context.resolved.ankiConnect.behavior.overwriteAudio,
+ 'Expected boolean.',
+ );
+ }
+ if (!hasOwn(behavior, 'overwriteImage')) {
+ mapLegacy(
+ 'overwriteImage',
+ asBoolean,
+ (value) => {
+ context.resolved.ankiConnect.behavior.overwriteImage = value;
+ },
+ context.resolved.ankiConnect.behavior.overwriteImage,
+ 'Expected boolean.',
+ );
+ }
+ if (!hasOwn(behavior, 'mediaInsertMode')) {
+ mapLegacy(
+ 'mediaInsertMode',
+ asMediaInsertMode,
+ (value) => {
+ context.resolved.ankiConnect.behavior.mediaInsertMode = value;
+ },
+ context.resolved.ankiConnect.behavior.mediaInsertMode,
+ "Expected 'append' or 'prepend'.",
+ );
+ }
+ if (!hasOwn(behavior, 'highlightWord')) {
+ mapLegacy(
+ 'highlightWord',
+ asBoolean,
+ (value) => {
+ context.resolved.ankiConnect.behavior.highlightWord = value;
+ },
+ context.resolved.ankiConnect.behavior.highlightWord,
+ 'Expected boolean.',
+ );
+ }
+ if (!hasOwn(behavior, 'notificationType')) {
+ mapLegacy(
+ 'notificationType',
+ asNotificationType,
+ (value) => {
+ context.resolved.ankiConnect.behavior.notificationType = value;
+ },
+ context.resolved.ankiConnect.behavior.notificationType,
+ "Expected 'osd', 'system', 'both', or 'none'.",
+ );
+ }
+ if (!hasOwn(behavior, 'autoUpdateNewCards')) {
+ mapLegacy(
+ 'autoUpdateNewCards',
+ asBoolean,
+ (value) => {
+ context.resolved.ankiConnect.behavior.autoUpdateNewCards = value;
+ },
+ context.resolved.ankiConnect.behavior.autoUpdateNewCards,
+ 'Expected boolean.',
+ );
+ }
+
+ const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record) : {};
+
+ const nPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
+ if (nPlusOneHighlightEnabled !== undefined) {
+ context.resolved.ankiConnect.nPlusOne.highlightEnabled = nPlusOneHighlightEnabled;
+ } else if (nPlusOneConfig.highlightEnabled !== undefined) {
+ context.warn(
+ 'ankiConnect.nPlusOne.highlightEnabled',
+ nPlusOneConfig.highlightEnabled,
+ context.resolved.ankiConnect.nPlusOne.highlightEnabled,
+ 'Expected boolean.',
+ );
+ context.resolved.ankiConnect.nPlusOne.highlightEnabled =
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
+ } else {
+ const legacyNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
+ if (legacyNPlusOneHighlightEnabled !== undefined) {
+ context.resolved.ankiConnect.nPlusOne.highlightEnabled = legacyNPlusOneHighlightEnabled;
+ context.warn(
+ 'ankiConnect.behavior.nPlusOneHighlightEnabled',
+ behavior.nPlusOneHighlightEnabled,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
+ 'Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled',
+ );
+ } else {
+ context.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) {
+ context.resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes;
+ } else {
+ context.warn(
+ 'ankiConnect.nPlusOne.refreshMinutes',
+ nPlusOneConfig.refreshMinutes,
+ context.resolved.ankiConnect.nPlusOne.refreshMinutes,
+ 'Expected a positive integer.',
+ );
+ context.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) {
+ context.resolved.ankiConnect.nPlusOne.refreshMinutes = legacyNPlusOneRefreshMinutes;
+ context.warn(
+ 'ankiConnect.behavior.nPlusOneRefreshMinutes',
+ behavior.nPlusOneRefreshMinutes,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
+ 'Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes',
+ );
+ } else {
+ context.warn(
+ 'ankiConnect.behavior.nPlusOneRefreshMinutes',
+ behavior.nPlusOneRefreshMinutes,
+ context.resolved.ankiConnect.nPlusOne.refreshMinutes,
+ 'Expected a positive integer.',
+ );
+ context.resolved.ankiConnect.nPlusOne.refreshMinutes =
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
+ }
+ } else {
+ context.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) {
+ context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords;
+ } else {
+ context.warn(
+ 'ankiConnect.nPlusOne.minSentenceWords',
+ nPlusOneConfig.minSentenceWords,
+ context.resolved.ankiConnect.nPlusOne.minSentenceWords,
+ 'Expected a positive integer.',
+ );
+ context.resolved.ankiConnect.nPlusOne.minSentenceWords =
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
+ }
+ } else {
+ context.resolved.ankiConnect.nPlusOne.minSentenceWords =
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
+ }
+
+ const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
+ const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
+ const hasValidNPlusOneMatchMode =
+ nPlusOneMatchMode === 'headword' || nPlusOneMatchMode === 'surface';
+ const hasValidLegacyMatchMode =
+ legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
+ if (hasValidNPlusOneMatchMode) {
+ context.resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode;
+ } else if (nPlusOneMatchMode !== undefined) {
+ context.warn(
+ 'ankiConnect.nPlusOne.matchMode',
+ nPlusOneConfig.matchMode,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
+ "Expected 'headword' or 'surface'.",
+ );
+ context.resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
+ } else if (legacyNPlusOneMatchMode !== undefined) {
+ if (hasValidLegacyMatchMode) {
+ context.resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode;
+ context.warn(
+ 'ankiConnect.behavior.nPlusOneMatchMode',
+ behavior.nPlusOneMatchMode,
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
+ 'Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode',
+ );
+ } else {
+ context.warn(
+ 'ankiConnect.behavior.nPlusOneMatchMode',
+ behavior.nPlusOneMatchMode,
+ context.resolved.ankiConnect.nPlusOne.matchMode,
+ "Expected 'headword' or 'surface'.",
+ );
+ context.resolved.ankiConnect.nPlusOne.matchMode =
+ DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
+ }
+ } else {
+ context.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) {
+ context.resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)];
+ } else if (nPlusOneDecks.length > 0) {
+ context.warn(
+ 'ankiConnect.nPlusOne.decks',
+ nPlusOneDecks,
+ context.resolved.ankiConnect.nPlusOne.decks,
+ 'Expected an array of strings.',
+ );
+ } else {
+ context.resolved.ankiConnect.nPlusOne.decks = [];
+ }
+ } else if (nPlusOneDecks !== undefined) {
+ context.warn(
+ 'ankiConnect.nPlusOne.decks',
+ nPlusOneDecks,
+ context.resolved.ankiConnect.nPlusOne.decks,
+ 'Expected an array of strings.',
+ );
+ context.resolved.ankiConnect.nPlusOne.decks = [];
+ }
+
+ const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
+ if (nPlusOneHighlightColor !== undefined) {
+ context.resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor;
+ } else if (nPlusOneConfig.nPlusOne !== undefined) {
+ context.warn(
+ 'ankiConnect.nPlusOne.nPlusOne',
+ nPlusOneConfig.nPlusOne,
+ context.resolved.ankiConnect.nPlusOne.nPlusOne,
+ 'Expected a hex color value.',
+ );
+ context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne;
+ }
+
+ const nPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
+ if (nPlusOneKnownWordColor !== undefined) {
+ context.resolved.ankiConnect.nPlusOne.knownWord = nPlusOneKnownWordColor;
+ } else if (nPlusOneConfig.knownWord !== undefined) {
+ context.warn(
+ 'ankiConnect.nPlusOne.knownWord',
+ nPlusOneConfig.knownWord,
+ context.resolved.ankiConnect.nPlusOne.knownWord,
+ 'Expected a hex color value.',
+ );
+ context.resolved.ankiConnect.nPlusOne.knownWord = DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord;
+ }
+
+ if (
+ context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' &&
+ context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' &&
+ context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled'
+ ) {
+ context.warn(
+ 'ankiConnect.isKiku.fieldGrouping',
+ context.resolved.ankiConnect.isKiku.fieldGrouping,
+ DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
+ 'Expected auto, manual, or disabled.',
+ );
+ context.resolved.ankiConnect.isKiku.fieldGrouping =
+ DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
+ }
+}
diff --git a/src/config/resolve/context.ts b/src/config/resolve/context.ts
new file mode 100644
index 0000000..abae21d
--- /dev/null
+++ b/src/config/resolve/context.ts
@@ -0,0 +1,30 @@
+import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types';
+import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions';
+import { createWarningCollector } from '../warnings';
+import { isObject } from './shared';
+
+export interface ResolveContext {
+ src: Record;
+ resolved: ResolvedConfig;
+ warn(path: string, value: unknown, fallback: unknown, message: string): void;
+}
+
+export type ResolveConfigApplier = (context: ResolveContext) => void;
+
+export function createResolveContext(raw: RawConfig): {
+ context: ResolveContext;
+ warnings: ConfigValidationWarning[];
+} {
+ const resolved = deepCloneConfig(DEFAULT_CONFIG);
+ const { warnings, warn } = createWarningCollector();
+ const src = isObject(raw) ? raw : {};
+
+ return {
+ context: {
+ src,
+ resolved,
+ warn,
+ },
+ warnings,
+ };
+}
diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts
new file mode 100644
index 0000000..f26026b
--- /dev/null
+++ b/src/config/resolve/core-domains.ts
@@ -0,0 +1,179 @@
+import { ResolveContext } from './context';
+import { asBoolean, asNumber, asString, isObject } from './shared';
+
+export function applyCoreDomainConfig(context: ResolveContext): void {
+ const { src, resolved, warn } = context;
+
+ 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;
+ }
+ }
+}
diff --git a/src/config/resolve/immersion-tracking.ts b/src/config/resolve/immersion-tracking.ts
new file mode 100644
index 0000000..883a4aa
--- /dev/null
+++ b/src/config/resolve/immersion-tracking.ts
@@ -0,0 +1,173 @@
+import { ResolveContext } from './context';
+import { asBoolean, asNumber, asString, isObject } from './shared';
+
+export function applyImmersionTrackingConfig(context: ResolveContext): void {
+ const { src, resolved, warn } = context;
+
+ 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.',
+ );
+ }
+ }
+}
diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts
new file mode 100644
index 0000000..d517889
--- /dev/null
+++ b/src/config/resolve/integrations.ts
@@ -0,0 +1,128 @@
+import { ResolveContext } from './context';
+import { asBoolean, asNumber, asString, isObject } from './shared';
+
+export function applyIntegrationConfig(context: ResolveContext): void {
+ const { src, resolved, warn } = context;
+
+ 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 (isObject(src.discordPresence)) {
+ const enabled = asBoolean(src.discordPresence.enabled);
+ if (enabled !== undefined) {
+ resolved.discordPresence.enabled = enabled;
+ } else if (src.discordPresence.enabled !== undefined) {
+ warn(
+ 'discordPresence.enabled',
+ src.discordPresence.enabled,
+ resolved.discordPresence.enabled,
+ 'Expected boolean.',
+ );
+ }
+
+ const updateIntervalMs = asNumber(src.discordPresence.updateIntervalMs);
+ if (updateIntervalMs !== undefined) {
+ resolved.discordPresence.updateIntervalMs = Math.max(1_000, Math.floor(updateIntervalMs));
+ } else if (src.discordPresence.updateIntervalMs !== undefined) {
+ warn(
+ 'discordPresence.updateIntervalMs',
+ src.discordPresence.updateIntervalMs,
+ resolved.discordPresence.updateIntervalMs,
+ 'Expected number.',
+ );
+ }
+
+ const debounceMs = asNumber(src.discordPresence.debounceMs);
+ if (debounceMs !== undefined) {
+ resolved.discordPresence.debounceMs = Math.max(0, Math.floor(debounceMs));
+ } else if (src.discordPresence.debounceMs !== undefined) {
+ warn(
+ 'discordPresence.debounceMs',
+ src.discordPresence.debounceMs,
+ resolved.discordPresence.debounceMs,
+ 'Expected number.',
+ );
+ }
+ }
+}
diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts
new file mode 100644
index 0000000..6802875
--- /dev/null
+++ b/src/config/resolve/jellyfin.test.ts
@@ -0,0 +1,64 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createResolveContext } from './context';
+import { applyIntegrationConfig } from './integrations';
+
+test('jellyfin directPlayContainers are normalized', () => {
+ const { context } = createResolveContext({
+ jellyfin: {
+ directPlayContainers: [' MKV ', 'mp4', '', ' WebM ', 42 as unknown as string],
+ },
+ });
+
+ applyIntegrationConfig(context);
+
+ assert.deepEqual(context.resolved.jellyfin.directPlayContainers, ['mkv', 'mp4', 'webm']);
+});
+
+test('jellyfin legacy auth keys are ignored by resolver', () => {
+ const { context } = createResolveContext({
+ jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
+ });
+
+ applyIntegrationConfig(context);
+
+ assert.equal('accessToken' in (context.resolved.jellyfin as Record), false);
+ assert.equal('userId' in (context.resolved.jellyfin as Record), false);
+});
+
+test('discordPresence fields are parsed and clamped', () => {
+ const { context } = createResolveContext({
+ discordPresence: {
+ enabled: true,
+ updateIntervalMs: 500,
+ debounceMs: -100,
+ },
+ });
+
+ applyIntegrationConfig(context);
+
+ assert.equal(context.resolved.discordPresence.enabled, true);
+ assert.equal(context.resolved.discordPresence.updateIntervalMs, 1000);
+ assert.equal(context.resolved.discordPresence.debounceMs, 0);
+});
+
+test('discordPresence invalid values warn and keep defaults', () => {
+ const { context, warnings } = createResolveContext({
+ discordPresence: {
+ enabled: 'true' as never,
+ updateIntervalMs: 'fast' as never,
+ debounceMs: null as never,
+ },
+ });
+
+ applyIntegrationConfig(context);
+
+ assert.equal(context.resolved.discordPresence.enabled, false);
+ assert.equal(context.resolved.discordPresence.updateIntervalMs, 3_000);
+ assert.equal(context.resolved.discordPresence.debounceMs, 750);
+
+ const warnedPaths = warnings.map((warning) => warning.path);
+ assert.ok(warnedPaths.includes('discordPresence.enabled'));
+ assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
+ assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
+});
diff --git a/src/config/resolve/shared.ts b/src/config/resolve/shared.ts
new file mode 100644
index 0000000..2490f91
--- /dev/null
+++ b/src/config/resolve/shared.ts
@@ -0,0 +1,38 @@
+export function isObject(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+export function asNumber(value: unknown): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
+}
+
+export function asString(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined;
+}
+
+export 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})$/;
+
+export function asColor(value: unknown): string | undefined {
+ if (typeof value !== 'string') return undefined;
+ const text = value.trim();
+ return hexColorPattern.test(text) ? text : undefined;
+}
+
+export 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];
+}
diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts
new file mode 100644
index 0000000..2b0144a
--- /dev/null
+++ b/src/config/resolve/subtitle-domains.ts
@@ -0,0 +1,239 @@
+import { ResolvedConfig } from '../../types';
+import { ResolveContext } from './context';
+import {
+ asBoolean,
+ asColor,
+ asFrequencyBandedColors,
+ asNumber,
+ asString,
+ isObject,
+} from './shared';
+
+export function applySubtitleDomainConfig(context: ResolveContext): void {
+ const { src, resolved, warn } = context;
+
+ 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.subtitleStyle)) {
+ const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
+ const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
+ const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
+ 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 hoverTokenColor = asColor((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor);
+ if (hoverTokenColor !== undefined) {
+ resolved.subtitleStyle.hoverTokenColor = hoverTokenColor;
+ } else if ((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor !== undefined) {
+ resolved.subtitleStyle.hoverTokenColor = fallbackSubtitleStyleHoverTokenColor;
+ warn(
+ 'subtitleStyle.hoverTokenColor',
+ (src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
+ resolved.subtitleStyle.hoverTokenColor,
+ 'Expected hex color.',
+ );
+ }
+
+ 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.',
+ );
+ }
+ }
+}
diff --git a/src/config/resolve/subtitle-style.test.ts b/src/config/resolve/subtitle-style.test.ts
new file mode 100644
index 0000000..43c7a3d
--- /dev/null
+++ b/src/config/resolve/subtitle-style.test.ts
@@ -0,0 +1,29 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createResolveContext } from './context';
+import { applySubtitleDomainConfig } from './subtitle-domains';
+
+test('subtitleStyle preserveLineBreaks falls back while merge is preserved', () => {
+ const { context, warnings } = createResolveContext({
+ subtitleStyle: {
+ preserveLineBreaks: 'invalid' as unknown as boolean,
+ backgroundColor: 'rgb(1, 2, 3, 0.5)',
+ secondary: {
+ fontColor: 'yellow',
+ },
+ },
+ });
+
+ applySubtitleDomainConfig(context);
+
+ assert.equal(context.resolved.subtitleStyle.preserveLineBreaks, false);
+ assert.equal(context.resolved.subtitleStyle.backgroundColor, 'rgb(1, 2, 3, 0.5)');
+ assert.equal(context.resolved.subtitleStyle.secondary.fontColor, 'yellow');
+ assert.ok(
+ warnings.some(
+ (warning) =>
+ warning.path === 'subtitleStyle.preserveLineBreaks' &&
+ warning.message === 'Expected boolean.',
+ ),
+ );
+});
diff --git a/src/config/resolve/top-level.ts b/src/config/resolve/top-level.ts
new file mode 100644
index 0000000..1f8f87f
--- /dev/null
+++ b/src/config/resolve/top-level.ts
@@ -0,0 +1,28 @@
+import { ResolveContext } from './context';
+import { asBoolean } from './shared';
+
+export function applyTopLevelConfig(context: ResolveContext): void {
+ const { src, resolved, warn } = context;
+ 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 (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.',
+ );
+ }
+}
diff --git a/src/config/service.ts b/src/config/service.ts
new file mode 100644
index 0000000..339c581
--- /dev/null
+++ b/src/config/service.ts
@@ -0,0 +1,116 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
+import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
+import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
+import { resolveConfig } from './resolve';
+
+export type ReloadConfigStrictResult =
+ | {
+ ok: true;
+ config: ResolvedConfig;
+ warnings: ConfigValidationWarning[];
+ path: string;
+ }
+ | {
+ ok: false;
+ error: string;
+ path: string;
+ };
+
+export class ConfigStartupParseError extends Error {
+ readonly path: string;
+ readonly parseError: string;
+
+ constructor(configPath: string, parseError: string) {
+ super(
+ `Failed to parse startup config at ${configPath}: ${parseError}. Fix the config file and restart SubMiner.`,
+ );
+ this.name = 'ConfigStartupParseError';
+ this.path = configPath;
+ this.parseError = parseError;
+ }
+}
+
+export class ConfigService {
+ private readonly configPaths: ConfigPaths;
+ private rawConfig: RawConfig = {};
+ private resolvedConfig: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG);
+ private warnings: ConfigValidationWarning[] = [];
+ private configPathInUse!: string;
+
+ constructor(configDir: string) {
+ this.configPaths = {
+ configDir,
+ configFileJsonc: path.join(configDir, 'config.jsonc'),
+ configFileJson: path.join(configDir, 'config.json'),
+ };
+ const loadResult = loadRawConfigStrict(this.configPaths);
+ if (!loadResult.ok) {
+ throw new ConfigStartupParseError(loadResult.path, loadResult.error);
+ }
+ this.applyResolvedConfig(loadResult.config, loadResult.path);
+ }
+
+ getConfigPath(): string {
+ return this.configPathInUse;
+ }
+
+ getConfig(): ResolvedConfig {
+ return deepCloneConfig(this.resolvedConfig);
+ }
+
+ getRawConfig(): RawConfig {
+ return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig;
+ }
+
+ getWarnings(): ConfigValidationWarning[] {
+ return [...this.warnings];
+ }
+
+ reloadConfig(): ResolvedConfig {
+ const { config, path: configPath } = loadRawConfig(this.configPaths);
+ return this.applyResolvedConfig(config, configPath);
+ }
+
+ reloadConfigStrict(): ReloadConfigStrictResult {
+ const loadResult = loadRawConfigStrict(this.configPaths);
+ if (!loadResult.ok) {
+ return loadResult;
+ }
+
+ const { config, path: configPath } = loadResult;
+ const resolvedConfig = this.applyResolvedConfig(config, configPath);
+ return {
+ ok: true,
+ config: resolvedConfig,
+ warnings: this.getWarnings(),
+ path: configPath,
+ };
+ }
+
+ saveRawConfig(config: RawConfig): void {
+ if (!fs.existsSync(this.configPaths.configDir)) {
+ fs.mkdirSync(this.configPaths.configDir, { recursive: true });
+ }
+ const targetPath = this.configPathInUse.endsWith('.json')
+ ? this.configPathInUse
+ : this.configPaths.configFileJsonc;
+ fs.writeFileSync(targetPath, JSON.stringify(config, null, 2));
+ this.applyResolvedConfig(config, targetPath);
+ }
+
+ patchRawConfig(patch: RawConfig): void {
+ const merged = deepMergeRawConfig(this.getRawConfig(), patch);
+ this.saveRawConfig(merged);
+ }
+
+ private applyResolvedConfig(config: RawConfig, configPath: string): ResolvedConfig {
+ this.rawConfig = config;
+ this.configPathInUse = configPath;
+ const { resolved, warnings } = resolveConfig(config);
+ this.resolvedConfig = resolved;
+ this.warnings = warnings;
+ return this.getConfig();
+ }
+}
diff --git a/src/config/template.ts b/src/config/template.ts
new file mode 100644
index 0000000..325e90d
--- /dev/null
+++ b/src/config/template.ts
@@ -0,0 +1,135 @@
+import { ResolvedConfig } from '../types';
+import {
+ CONFIG_OPTION_REGISTRY,
+ CONFIG_TEMPLATE_SECTIONS,
+ DEFAULT_CONFIG,
+ deepCloneConfig,
+} from './definitions';
+
+const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
+const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
+ CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
+);
+
+function normalizeCommentText(value: string): string {
+ return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
+}
+
+function humanizeKey(key: string): string {
+ const spaced = key
+ .replace(/_/g, ' ')
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .toLowerCase();
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
+}
+
+function buildInlineOptionComment(path: string, value: unknown): string {
+ const registryEntry = OPTION_REGISTRY_BY_PATH.get(path);
+ const baseDescription = registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
+ const description =
+ baseDescription && baseDescription.trim().length > 0
+ ? normalizeCommentText(baseDescription)
+ : `${humanizeKey(path.split('.').at(-1) ?? path)} setting.`;
+
+ if (registryEntry?.enumValues?.length) {
+ return `${description} Values: ${registryEntry.enumValues.join(' | ')}`;
+ }
+ if (typeof value === 'boolean') {
+ return `${description} Values: true | false`;
+ }
+ return description;
+}
+
+function renderValue(value: unknown, indent = 0, path = ''): string {
+ const pad = ' '.repeat(indent);
+ const nextPad = ' '.repeat(indent + 2);
+
+ if (value === null) return 'null';
+ if (typeof value === 'string') return JSON.stringify(value);
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) return '[]';
+ const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2, `${path}[]`)}`);
+ return `\n${items.join(',\n')}\n${pad}`.replace(/^/, '[').concat(']');
+ }
+
+ if (typeof value === 'object') {
+ const entries = Object.entries(value as Record).filter(
+ ([, child]) => child !== undefined,
+ );
+ if (entries.length === 0) return '{}';
+ const lines = entries.map(([key, child], index) => {
+ const isLast = index === entries.length - 1;
+ const trailingComma = isLast ? '' : ',';
+ const childPath = path ? `${path}.${key}` : key;
+ const renderedChild = renderValue(child, indent + 2, childPath);
+ const comment = buildInlineOptionComment(childPath, child);
+ if (renderedChild.startsWith('\n')) {
+ return `${nextPad}${JSON.stringify(key)}: /* ${comment} */ ${renderedChild}${trailingComma}`;
+ }
+ return `${nextPad}${JSON.stringify(key)}: ${renderedChild}${trailingComma} // ${comment}`;
+ });
+ return `\n${lines.join('\n')}\n${pad}`.replace(/^/, '{').concat('}');
+ }
+
+ return 'null';
+}
+
+function renderSection(
+ key: keyof ResolvedConfig,
+ value: unknown,
+ isLast: boolean,
+ comments: string[],
+): string {
+ const lines: string[] = [];
+ lines.push(' // ==========================================');
+ for (const comment of comments) {
+ lines.push(` // ${comment}`);
+ }
+ lines.push(' // ==========================================');
+ const inlineComment = buildInlineOptionComment(String(key), value);
+ const renderedValue = renderValue(value, 2, String(key));
+ if (renderedValue.startsWith('\n')) {
+ lines.push(
+ ` ${JSON.stringify(key)}: /* ${inlineComment} */ ${renderedValue}${isLast ? '' : ','}`,
+ );
+ } else {
+ lines.push(
+ ` ${JSON.stringify(key)}: ${renderedValue}${isLast ? '' : ','} // ${inlineComment}`,
+ );
+ }
+ return lines.join('\n');
+}
+
+export function generateConfigTemplate(
+ config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG),
+): string {
+ const lines: string[] = [];
+ lines.push('/**');
+ lines.push(' * SubMiner Example Configuration File');
+ lines.push(' *');
+ lines.push(' * This file is auto-generated from src/config/definitions.ts.');
+ lines.push(
+ ' * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.',
+ );
+ lines.push(' */');
+ lines.push('{');
+
+ CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => {
+ lines.push('');
+ const comments = [section.title, ...section.description, ...(section.notes ?? [])];
+ lines.push(
+ renderSection(
+ section.key,
+ config[section.key],
+ index === CONFIG_TEMPLATE_SECTIONS.length - 1,
+ comments,
+ ),
+ );
+ });
+
+ lines.push('}');
+ lines.push('');
+ return lines.join('\n');
+}
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/anilist/anilist-token-store.test.ts b/src/core/services/anilist/anilist-token-store.test.ts
new file mode 100644
index 0000000..cf9a4e7
--- /dev/null
+++ b/src/core/services/anilist/anilist-token-store.test.ts
@@ -0,0 +1,85 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+import { createAnilistTokenStore, type SafeStorageLike } from './anilist-token-store';
+
+function createTempTokenFile(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-token-'));
+ return path.join(dir, 'token.json');
+}
+
+function createLogger() {
+ return {
+ info: (_message: string) => {},
+ warn: (_message: string) => {},
+ error: (_message: string) => {},
+ };
+}
+
+function createStorage(encryptionAvailable: boolean): SafeStorageLike {
+ return {
+ isEncryptionAvailable: () => encryptionAvailable,
+ encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'),
+ decryptString: (value: Buffer) => {
+ const raw = value.toString('utf-8');
+ return raw.startsWith('enc:') ? raw.slice(4) : raw;
+ },
+ };
+}
+
+test('anilist token store saves and loads encrypted token', () => {
+ const filePath = createTempTokenFile();
+ const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
+ store.saveToken(' demo-token ');
+
+ const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
+ encryptedToken?: string;
+ plaintextToken?: string;
+ };
+ assert.equal(typeof payload.encryptedToken, 'string');
+ assert.equal(payload.plaintextToken, undefined);
+ assert.equal(store.loadToken(), 'demo-token');
+});
+
+test('anilist token store falls back to plaintext when encryption unavailable', () => {
+ const filePath = createTempTokenFile();
+ const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false));
+ store.saveToken('plain-token');
+
+ const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
+ plaintextToken?: string;
+ };
+ assert.equal(payload.plaintextToken, 'plain-token');
+ assert.equal(store.loadToken(), 'plain-token');
+});
+
+test('anilist token store migrates legacy plaintext to encrypted', () => {
+ const filePath = createTempTokenFile();
+ fs.writeFileSync(
+ filePath,
+ JSON.stringify({ plaintextToken: 'legacy-token', updatedAt: Date.now() }),
+ 'utf-8',
+ );
+
+ const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
+ assert.equal(store.loadToken(), 'legacy-token');
+
+ const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
+ encryptedToken?: string;
+ plaintextToken?: string;
+ };
+ assert.equal(typeof payload.encryptedToken, 'string');
+ assert.equal(payload.plaintextToken, undefined);
+});
+
+test('anilist token store clears persisted token file', () => {
+ const filePath = createTempTokenFile();
+ const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
+ store.saveToken('to-clear');
+ assert.equal(fs.existsSync(filePath), true);
+ store.clearToken();
+ assert.equal(fs.existsSync(filePath), false);
+});
diff --git a/src/core/services/anilist/anilist-token-store.ts b/src/core/services/anilist/anilist-token-store.ts
new file mode 100644
index 0000000..d89ded1
--- /dev/null
+++ b/src/core/services/anilist/anilist-token-store.ts
@@ -0,0 +1,108 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as electron from 'electron';
+
+interface PersistedTokenPayload {
+ encryptedToken?: string;
+ plaintextToken?: string;
+ updatedAt?: number;
+}
+
+export interface AnilistTokenStore {
+ loadToken: () => string | null;
+ saveToken: (token: string) => void;
+ clearToken: () => void;
+}
+
+export interface SafeStorageLike {
+ isEncryptionAvailable: () => boolean;
+ encryptString: (value: string) => Buffer;
+ decryptString: (value: Buffer) => string;
+}
+
+function ensureDirectory(filePath: string): void {
+ const dir = path.dirname(filePath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+}
+
+function writePayload(filePath: string, payload: PersistedTokenPayload): void {
+ ensureDirectory(filePath);
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
+}
+
+export function createAnilistTokenStore(
+ filePath: string,
+ logger: {
+ info: (message: string) => void;
+ warn: (message: string, details?: unknown) => void;
+ error: (message: string, details?: unknown) => void;
+ },
+ storage: SafeStorageLike = electron.safeStorage,
+): AnilistTokenStore {
+ return {
+ loadToken(): string | null {
+ if (!fs.existsSync(filePath)) {
+ return null;
+ }
+ try {
+ const raw = fs.readFileSync(filePath, 'utf-8');
+ const parsed = JSON.parse(raw) as PersistedTokenPayload;
+ if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
+ const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
+ if (!storage.isEncryptionAvailable()) {
+ logger.warn('AniList token encryption is not available on this system.');
+ return null;
+ }
+ const decrypted = storage.decryptString(encrypted).trim();
+ return decrypted.length > 0 ? decrypted : null;
+ }
+ if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) {
+ // Legacy fallback: migrate plaintext token to encrypted storage on load.
+ const plaintext = parsed.plaintextToken.trim();
+ this.saveToken(plaintext);
+ return plaintext;
+ }
+ } catch (error) {
+ logger.error('Failed to read AniList token store.', error);
+ }
+ return null;
+ },
+
+ saveToken(token: string): void {
+ const trimmed = token.trim();
+ if (trimmed.length === 0) {
+ this.clearToken();
+ return;
+ }
+ try {
+ if (!storage.isEncryptionAvailable()) {
+ logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.');
+ writePayload(filePath, {
+ plaintextToken: trimmed,
+ updatedAt: Date.now(),
+ });
+ return;
+ }
+ const encrypted = storage.encryptString(trimmed);
+ writePayload(filePath, {
+ encryptedToken: encrypted.toString('base64'),
+ updatedAt: Date.now(),
+ });
+ } catch (error) {
+ logger.error('Failed to persist AniList token.', error);
+ }
+ },
+
+ clearToken(): void {
+ if (!fs.existsSync(filePath)) return;
+ try {
+ fs.unlinkSync(filePath);
+ logger.info('Cleared stored AniList token.');
+ } catch (error) {
+ logger.error('Failed to clear stored AniList token.', error);
+ }
+ },
+ };
+}
diff --git a/src/core/services/anilist/anilist-update-queue.test.ts b/src/core/services/anilist/anilist-update-queue.test.ts
new file mode 100644
index 0000000..dace595
--- /dev/null
+++ b/src/core/services/anilist/anilist-update-queue.test.ts
@@ -0,0 +1,93 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+import { createAnilistUpdateQueue } from './anilist-update-queue';
+
+function createTempQueueFile(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-queue-'));
+ return path.join(dir, 'queue.json');
+}
+
+function createLogger() {
+ const info: string[] = [];
+ const warn: string[] = [];
+ const error: string[] = [];
+ return {
+ info,
+ warn,
+ error,
+ logger: {
+ info: (message: string) => info.push(message),
+ warn: (message: string) => warn.push(message),
+ error: (message: string) => error.push(message),
+ },
+ };
+}
+
+test('anilist update queue enqueues, snapshots, and dequeues success', () => {
+ const queueFile = createTempQueueFile();
+ const loggerState = createLogger();
+ const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
+
+ queue.enqueue('k1', 'Demo', 1);
+ const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
+ assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
+ assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
+
+ queue.markSuccess('k1');
+ assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
+ pending: 0,
+ ready: 0,
+ deadLetter: 0,
+ });
+ assert.ok(loggerState.info.some((message) => message.includes('Queued AniList retry')));
+});
+
+test('anilist update queue applies retry backoff and dead-letter', () => {
+ const queueFile = createTempQueueFile();
+ const loggerState = createLogger();
+ const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
+
+ const now = 1_700_000_000_000;
+ queue.enqueue('k2', 'Backoff Demo', 2);
+
+ queue.markFailure('k2', 'fail-1', now);
+ const firstRetry = queue.nextReady(now);
+ assert.equal(firstRetry, null);
+
+ const pendingPayload = JSON.parse(fs.readFileSync(queueFile, 'utf-8')) as {
+ pending: Array<{ attemptCount: number; nextAttemptAt: number }>;
+ };
+ assert.equal(pendingPayload.pending[0]?.attemptCount, 1);
+ assert.equal(pendingPayload.pending[0]?.nextAttemptAt, now + 30_000);
+
+ for (let attempt = 2; attempt <= 8; attempt += 1) {
+ queue.markFailure('k2', `fail-${attempt}`, now);
+ }
+
+ const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
+ assert.deepEqual(snapshot, { pending: 0, ready: 0, deadLetter: 1 });
+ assert.ok(
+ loggerState.warn.some((message) =>
+ message.includes('AniList retry moved to dead-letter queue.'),
+ ),
+ );
+});
+
+test('anilist update queue persists and reloads from disk', () => {
+ const queueFile = createTempQueueFile();
+ const loggerState = createLogger();
+ const queueA = createAnilistUpdateQueue(queueFile, loggerState.logger);
+ queueA.enqueue('k3', 'Persist Demo', 3);
+
+ const queueB = createAnilistUpdateQueue(queueFile, loggerState.logger);
+ assert.deepEqual(queueB.getSnapshot(Number.MAX_SAFE_INTEGER), {
+ pending: 1,
+ ready: 1,
+ deadLetter: 0,
+ });
+ assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
+});
diff --git a/src/core/services/anilist/anilist-update-queue.ts b/src/core/services/anilist/anilist-update-queue.ts
new file mode 100644
index 0000000..71e1339
--- /dev/null
+++ b/src/core/services/anilist/anilist-update-queue.ts
@@ -0,0 +1,193 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+const INITIAL_BACKOFF_MS = 30_000;
+const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
+const MAX_ATTEMPTS = 8;
+const MAX_ITEMS = 500;
+
+export interface AnilistQueuedUpdate {
+ key: string;
+ title: string;
+ episode: number;
+ createdAt: number;
+ attemptCount: number;
+ nextAttemptAt: number;
+ lastError: string | null;
+}
+
+interface AnilistRetryQueuePayload {
+ pending?: AnilistQueuedUpdate[];
+ deadLetter?: AnilistQueuedUpdate[];
+}
+
+export interface AnilistRetryQueueSnapshot {
+ pending: number;
+ ready: number;
+ deadLetter: number;
+}
+
+export interface AnilistUpdateQueue {
+ enqueue: (key: string, title: string, episode: number) => void;
+ nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
+ markSuccess: (key: string) => void;
+ markFailure: (key: string, reason: string, nowMs?: number) => void;
+ getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
+}
+
+function ensureDir(filePath: string): void {
+ const dir = path.dirname(filePath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+}
+
+function clampBackoffMs(attemptCount: number): number {
+ const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
+ return Math.min(MAX_BACKOFF_MS, computed);
+}
+
+export function createAnilistUpdateQueue(
+ filePath: string,
+ logger: {
+ info: (message: string) => void;
+ warn: (message: string, details?: unknown) => void;
+ error: (message: string, details?: unknown) => void;
+ },
+): AnilistUpdateQueue {
+ let pending: AnilistQueuedUpdate[] = [];
+ let deadLetter: AnilistQueuedUpdate[] = [];
+
+ const persist = () => {
+ try {
+ ensureDir(filePath);
+ const payload: AnilistRetryQueuePayload = { pending, deadLetter };
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
+ } catch (error) {
+ logger.error('Failed to persist AniList retry queue.', error);
+ }
+ };
+
+ const load = () => {
+ if (!fs.existsSync(filePath)) {
+ return;
+ }
+ try {
+ const raw = fs.readFileSync(filePath, 'utf-8');
+ const parsed = JSON.parse(raw) as AnilistRetryQueuePayload;
+ const parsedPending = Array.isArray(parsed.pending) ? parsed.pending : [];
+ const parsedDeadLetter = Array.isArray(parsed.deadLetter) ? parsed.deadLetter : [];
+ pending = parsedPending
+ .filter(
+ (item): item is AnilistQueuedUpdate =>
+ item &&
+ typeof item.key === 'string' &&
+ typeof item.title === 'string' &&
+ typeof item.episode === 'number' &&
+ item.episode > 0 &&
+ typeof item.createdAt === 'number' &&
+ typeof item.attemptCount === 'number' &&
+ typeof item.nextAttemptAt === 'number' &&
+ (typeof item.lastError === 'string' || item.lastError === null),
+ )
+ .slice(0, MAX_ITEMS);
+ deadLetter = parsedDeadLetter
+ .filter(
+ (item): item is AnilistQueuedUpdate =>
+ item &&
+ typeof item.key === 'string' &&
+ typeof item.title === 'string' &&
+ typeof item.episode === 'number' &&
+ item.episode > 0 &&
+ typeof item.createdAt === 'number' &&
+ typeof item.attemptCount === 'number' &&
+ typeof item.nextAttemptAt === 'number' &&
+ (typeof item.lastError === 'string' || item.lastError === null),
+ )
+ .slice(0, MAX_ITEMS);
+ } catch (error) {
+ logger.error('Failed to load AniList retry queue.', error);
+ }
+ };
+
+ load();
+
+ return {
+ enqueue(key: string, title: string, episode: number): void {
+ const existing = pending.find((item) => item.key === key);
+ if (existing) {
+ return;
+ }
+ if (pending.length >= MAX_ITEMS) {
+ pending.shift();
+ }
+ pending.push({
+ key,
+ title,
+ episode,
+ createdAt: Date.now(),
+ attemptCount: 0,
+ nextAttemptAt: Date.now(),
+ lastError: null,
+ });
+ persist();
+ logger.info(`Queued AniList retry for "${title}" episode ${episode}.`);
+ },
+
+ nextReady(nowMs: number = Date.now()): AnilistQueuedUpdate | null {
+ const ready = pending.find((item) => item.nextAttemptAt <= nowMs);
+ return ready ?? null;
+ },
+
+ markSuccess(key: string): void {
+ const before = pending.length;
+ pending = pending.filter((item) => item.key !== key);
+ if (pending.length !== before) {
+ persist();
+ }
+ },
+
+ markFailure(key: string, reason: string, nowMs: number = Date.now()): void {
+ const item = pending.find((candidate) => candidate.key === key);
+ if (!item) {
+ return;
+ }
+ item.attemptCount += 1;
+ item.lastError = reason;
+ if (item.attemptCount >= MAX_ATTEMPTS) {
+ pending = pending.filter((candidate) => candidate.key !== key);
+ if (deadLetter.length >= MAX_ITEMS) {
+ deadLetter.shift();
+ }
+ deadLetter.push({
+ ...item,
+ nextAttemptAt: nowMs,
+ });
+ logger.warn('AniList retry moved to dead-letter queue.', {
+ key,
+ reason,
+ attempts: item.attemptCount,
+ });
+ persist();
+ return;
+ }
+ item.nextAttemptAt = nowMs + clampBackoffMs(item.attemptCount);
+ persist();
+ logger.warn('AniList retry scheduled with backoff.', {
+ key,
+ attemptCount: item.attemptCount,
+ nextAttemptAt: item.nextAttemptAt,
+ reason,
+ });
+ },
+
+ getSnapshot(nowMs: number = Date.now()): AnilistRetryQueueSnapshot {
+ const ready = pending.filter((item) => item.nextAttemptAt <= nowMs).length;
+ return {
+ pending: pending.length,
+ ready,
+ deadLetter: deadLetter.length,
+ };
+ },
+ };
+}
diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts
new file mode 100644
index 0000000..632d0e7
--- /dev/null
+++ b/src/core/services/anilist/anilist-updater.test.ts
@@ -0,0 +1,166 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as childProcess from 'child_process';
+
+import { guessAnilistMediaInfo, updateAnilistPostWatchProgress } from './anilist-updater';
+
+function createJsonResponse(payload: unknown): Response {
+ return new Response(JSON.stringify(payload), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+test('guessAnilistMediaInfo uses guessit output when available', async () => {
+ const originalExecFile = childProcess.execFile;
+ (
+ childProcess as unknown as {
+ execFile: typeof childProcess.execFile;
+ }
+ ).execFile = ((...args: unknown[]) => {
+ const callback = args[args.length - 1];
+ const cb =
+ typeof callback === 'function'
+ ? (callback as (error: Error | null, stdout: string, stderr: string) => void)
+ : null;
+ cb?.(null, JSON.stringify({ title: 'Guessit Title', episode: 7 }), '');
+ return {} as childProcess.ChildProcess;
+ }) as typeof childProcess.execFile;
+
+ try {
+ const result = await guessAnilistMediaInfo('/tmp/demo.mkv', null);
+ assert.deepEqual(result, {
+ title: 'Guessit Title',
+ episode: 7,
+ source: 'guessit',
+ });
+ } finally {
+ (
+ childProcess as unknown as {
+ execFile: typeof childProcess.execFile;
+ }
+ ).execFile = originalExecFile;
+ }
+});
+
+test('guessAnilistMediaInfo falls back to parser when guessit fails', async () => {
+ const originalExecFile = childProcess.execFile;
+ (
+ childProcess as unknown as {
+ execFile: typeof childProcess.execFile;
+ }
+ ).execFile = ((...args: unknown[]) => {
+ const callback = args[args.length - 1];
+ const cb =
+ typeof callback === 'function'
+ ? (callback as (error: Error | null, stdout: string, stderr: string) => void)
+ : null;
+ cb?.(new Error('guessit not found'), '', '');
+ return {} as childProcess.ChildProcess;
+ }) as typeof childProcess.execFile;
+
+ try {
+ const result = await guessAnilistMediaInfo('/tmp/My Anime S01E03.mkv', null);
+ assert.deepEqual(result, {
+ title: 'My Anime',
+ episode: 3,
+ source: 'fallback',
+ });
+ } finally {
+ (
+ childProcess as unknown as {
+ execFile: typeof childProcess.execFile;
+ }
+ ).execFile = originalExecFile;
+ }
+});
+
+test('updateAnilistPostWatchProgress updates progress when behind', async () => {
+ const originalFetch = globalThis.fetch;
+ let call = 0;
+ globalThis.fetch = (async () => {
+ call += 1;
+ if (call === 1) {
+ return createJsonResponse({
+ data: {
+ Page: {
+ media: [
+ {
+ id: 11,
+ episodes: 24,
+ title: { english: 'Demo Show', romaji: 'Demo Show' },
+ },
+ ],
+ },
+ },
+ });
+ }
+ if (call === 2) {
+ return createJsonResponse({
+ data: {
+ Media: {
+ id: 11,
+ mediaListEntry: { progress: 2, status: 'CURRENT' },
+ },
+ },
+ });
+ }
+ return createJsonResponse({
+ data: { SaveMediaListEntry: { progress: 3, status: 'CURRENT' } },
+ });
+ }) as typeof fetch;
+
+ try {
+ const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3);
+ assert.equal(result.status, 'updated');
+ assert.match(result.message, /episode 3/i);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
+ const originalFetch = globalThis.fetch;
+ let call = 0;
+ globalThis.fetch = (async () => {
+ call += 1;
+ if (call === 1) {
+ return createJsonResponse({
+ data: {
+ Page: {
+ media: [{ id: 22, episodes: 12, title: { english: 'Skip Show' } }],
+ },
+ },
+ });
+ }
+ return createJsonResponse({
+ data: {
+ Media: { id: 22, mediaListEntry: { progress: 12, status: 'CURRENT' } },
+ },
+ });
+ }) as typeof fetch;
+
+ try {
+ const result = await updateAnilistPostWatchProgress('token', 'Skip Show', 10);
+ assert.equal(result.status, 'skipped');
+ assert.match(result.message, /already at episode/i);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('updateAnilistPostWatchProgress returns error when search fails', async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = (async () =>
+ createJsonResponse({
+ errors: [{ message: 'bad request' }],
+ })) as typeof fetch;
+
+ try {
+ const result = await updateAnilistPostWatchProgress('token', 'Bad', 1);
+ assert.equal(result.status, 'error');
+ assert.match(result.message, /search failed/i);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts
new file mode 100644
index 0000000..4012651
--- /dev/null
+++ b/src/core/services/anilist/anilist-updater.ts
@@ -0,0 +1,299 @@
+import * as childProcess from 'child_process';
+
+import { parseMediaInfo } from '../../../jimaku/utils';
+
+const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
+
+export interface AnilistMediaGuess {
+ title: string;
+ episode: number | null;
+ source: 'guessit' | 'fallback';
+}
+
+export interface AnilistPostWatchUpdateResult {
+ status: 'updated' | 'skipped' | 'error';
+ message: string;
+}
+
+interface AnilistGraphQlError {
+ message?: string;
+}
+
+interface AnilistGraphQlResponse {
+ data?: T;
+ errors?: AnilistGraphQlError[];
+}
+
+interface AnilistSearchData {
+ Page?: {
+ media?: Array<{
+ id: number;
+ episodes: number | null;
+ title?: {
+ romaji?: string | null;
+ english?: string | null;
+ native?: string | null;
+ };
+ }>;
+ };
+}
+
+interface AnilistMediaEntryData {
+ Media?: {
+ id: number;
+ mediaListEntry?: {
+ progress?: number | null;
+ status?: string | null;
+ } | null;
+ } | null;
+}
+
+interface AnilistSaveEntryData {
+ SaveMediaListEntry?: {
+ progress?: number | null;
+ status?: string | null;
+ };
+}
+
+function runGuessit(target: string): Promise {
+ return new Promise((resolve, reject) => {
+ childProcess.execFile(
+ 'guessit',
+ [target, '--json'],
+ { timeout: 5000, maxBuffer: 1024 * 1024 },
+ (error, stdout) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve(stdout);
+ },
+ );
+ });
+}
+
+function firstString(value: unknown): string | null {
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const candidate = firstString(item);
+ if (candidate) return candidate;
+ }
+ }
+ return null;
+}
+
+function firstPositiveInteger(value: unknown): number | null {
+ if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
+ return value;
+ }
+ if (typeof value === 'string') {
+ const parsed = Number.parseInt(value, 10);
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const candidate = firstPositiveInteger(item);
+ if (candidate !== null) return candidate;
+ }
+ }
+ return null;
+}
+
+function normalizeTitle(text: string): string {
+ return text.trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+async function anilistGraphQl(
+ accessToken: string,
+ query: string,
+ variables: Record,
+): Promise> {
+ try {
+ const response = await fetch(ANILIST_GRAPHQL_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({ query, variables }),
+ });
+
+ const payload = (await response.json()) as AnilistGraphQlResponse;
+ return payload;
+ } catch (error) {
+ return {
+ errors: [
+ {
+ message: error instanceof Error ? error.message : String(error),
+ },
+ ],
+ };
+ }
+}
+
+function firstErrorMessage(response: AnilistGraphQlResponse): string | null {
+ const firstError = response.errors?.find((item) => Boolean(item?.message));
+ return firstError?.message ?? null;
+}
+
+function pickBestSearchResult(
+ title: string,
+ episode: number,
+ media: Array<{
+ id: number;
+ episodes: number | null;
+ title?: {
+ romaji?: string | null;
+ english?: string | null;
+ native?: string | null;
+ };
+ }>,
+): { id: number; title: string } | null {
+ const filtered = media.filter((item) => {
+ const totalEpisodes = item.episodes;
+ return totalEpisodes === null || totalEpisodes >= episode;
+ });
+ const candidates = filtered.length > 0 ? filtered : media;
+ if (candidates.length === 0) return null;
+
+ const normalizedTarget = normalizeTitle(title);
+ const exact = candidates.find((item) => {
+ const titles = [item.title?.romaji, item.title?.english, item.title?.native]
+ .filter((value): value is string => typeof value === 'string')
+ .map((value) => normalizeTitle(value));
+ return titles.includes(normalizedTarget);
+ });
+
+ const selected = exact ?? candidates[0]!;
+ const selectedTitle =
+ selected.title?.english || selected.title?.romaji || selected.title?.native || title;
+ return { id: selected.id, title: selectedTitle };
+}
+
+export async function guessAnilistMediaInfo(
+ mediaPath: string | null,
+ mediaTitle: string | null,
+): Promise {
+ const target = mediaPath ?? mediaTitle;
+
+ if (target && target.trim().length > 0) {
+ try {
+ const stdout = await runGuessit(target);
+ const parsed = JSON.parse(stdout) as Record;
+ const title = firstString(parsed.title);
+ const episode = firstPositiveInteger(parsed.episode);
+ if (title) {
+ return { title, episode, source: 'guessit' };
+ }
+ } catch {
+ // Ignore guessit failures and fall back to internal parser.
+ }
+ }
+
+ const fallbackTarget = mediaPath ?? mediaTitle;
+ const parsed = parseMediaInfo(fallbackTarget);
+ if (!parsed.title.trim()) {
+ return null;
+ }
+ return {
+ title: parsed.title.trim(),
+ episode: parsed.episode,
+ source: 'fallback',
+ };
+}
+
+export async function updateAnilistPostWatchProgress(
+ accessToken: string,
+ title: string,
+ episode: number,
+): Promise {
+ const searchResponse = await anilistGraphQl(
+ accessToken,
+ `
+ query ($search: String!) {
+ Page(perPage: 5) {
+ media(search: $search, type: ANIME) {
+ id
+ episodes
+ title {
+ romaji
+ english
+ native
+ }
+ }
+ }
+ }
+ `,
+ { search: title },
+ );
+ const searchError = firstErrorMessage(searchResponse);
+ if (searchError) {
+ return {
+ status: 'error',
+ message: `AniList search failed: ${searchError}`,
+ };
+ }
+
+ const media = searchResponse.data?.Page?.media ?? [];
+ const picked = pickBestSearchResult(title, episode, media);
+ if (!picked) {
+ return { status: 'error', message: 'AniList search returned no matches.' };
+ }
+
+ const entryResponse = await anilistGraphQl(
+ accessToken,
+ `
+ query ($mediaId: Int!) {
+ Media(id: $mediaId, type: ANIME) {
+ id
+ mediaListEntry {
+ progress
+ status
+ }
+ }
+ }
+ `,
+ { mediaId: picked.id },
+ );
+ const entryError = firstErrorMessage(entryResponse);
+ if (entryError) {
+ return {
+ status: 'error',
+ message: `AniList entry lookup failed: ${entryError}`,
+ };
+ }
+
+ const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
+ if (typeof currentProgress === 'number' && currentProgress >= episode) {
+ return {
+ status: 'skipped',
+ message: `AniList already at episode ${currentProgress} (${picked.title}).`,
+ };
+ }
+
+ const saveResponse = await anilistGraphQl(
+ accessToken,
+ `
+ mutation ($mediaId: Int!, $progress: Int!) {
+ SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) {
+ progress
+ status
+ }
+ }
+ `,
+ { mediaId: picked.id, progress: episode },
+ );
+ const saveError = firstErrorMessage(saveResponse);
+ if (saveError) {
+ return { status: 'error', message: `AniList update failed: ${saveError}` };
+ }
+
+ return {
+ status: 'updated',
+ message: `AniList updated "${picked.title}" to episode ${episode}.`,
+ };
+}
diff --git a/src/core/services/anki-jimaku-ipc.test.ts b/src/core/services/anki-jimaku-ipc.test.ts
new file mode 100644
index 0000000..9f6afcc
--- /dev/null
+++ b/src/core/services/anki-jimaku-ipc.test.ts
@@ -0,0 +1,153 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerAnkiJimakuIpcHandlers } from './anki-jimaku-ipc';
+import { IPC_CHANNELS } from '../../shared/ipc/contracts';
+
+function createFakeRegistrar(): {
+ registrar: {
+ on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
+ handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
+ };
+ onHandlers: Map void>;
+ handleHandlers: Map unknown>;
+} {
+ const onHandlers = new Map void>();
+ const handleHandlers = new Map unknown>();
+ return {
+ registrar: {
+ on: (channel, listener) => {
+ onHandlers.set(channel, listener);
+ },
+ handle: (channel, listener) => {
+ handleHandlers.set(channel, listener);
+ },
+ },
+ onHandlers,
+ handleHandlers,
+ };
+}
+
+test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
+ const { registrar, handleHandlers } = createFakeRegistrar();
+ let previewCalls = 0;
+ registerAnkiJimakuIpcHandlers(
+ {
+ setAnkiConnectEnabled: () => {},
+ clearAnkiHistory: () => {},
+ refreshKnownWords: async () => {},
+ respondFieldGrouping: () => {},
+ buildKikuMergePreview: async () => {
+ previewCalls += 1;
+ return { ok: true };
+ },
+ getJimakuMediaInfo: () => ({
+ title: 'x',
+ season: null,
+ episode: null,
+ confidence: 'high',
+ filename: 'x.mkv',
+ rawTitle: 'x',
+ }),
+ searchJimakuEntries: async () => ({ ok: true, data: [] }),
+ listJimakuFiles: async () => ({ ok: true, data: [] }),
+ resolveJimakuApiKey: async () => 'token',
+ getCurrentMediaPath: () => '/tmp/a.mkv',
+ isRemoteMediaPath: () => false,
+ downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
+ onDownloadedSubtitle: () => {},
+ },
+ registrar,
+ );
+
+ const previewHandler = handleHandlers.get(IPC_CHANNELS.request.kikuBuildMergePreview);
+ assert.ok(previewHandler);
+ const invalidPreviewResult = await previewHandler!({}, null);
+ assert.deepEqual(invalidPreviewResult, {
+ ok: false,
+ error: 'Invalid merge preview request payload',
+ });
+ await previewHandler!({}, { keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: false });
+ assert.equal(previewCalls, 1);
+
+ const searchHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuSearchEntries);
+ assert.ok(searchHandler);
+ const invalidSearchResult = await searchHandler!({}, { query: 12 });
+ assert.deepEqual(invalidSearchResult, {
+ ok: false,
+ error: { error: 'Invalid Jimaku search query payload', code: 400 },
+ });
+
+ const filesHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuListFiles);
+ assert.ok(filesHandler);
+ const invalidFilesResult = await filesHandler!({}, { entryId: 'x' });
+ assert.deepEqual(invalidFilesResult, {
+ ok: false,
+ error: { error: 'Invalid Jimaku files query payload', code: 400 },
+ });
+
+ const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuDownloadFile);
+ assert.ok(downloadHandler);
+ const invalidDownloadResult = await downloadHandler!({}, { entryId: 1, url: '/x' });
+ assert.deepEqual(invalidDownloadResult, {
+ ok: false,
+ error: { error: 'Invalid Jimaku download query payload', code: 400 },
+ });
+});
+
+test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
+ const { registrar, onHandlers } = createFakeRegistrar();
+ const fieldGroupingChoices: unknown[] = [];
+ const enabledStates: boolean[] = [];
+ registerAnkiJimakuIpcHandlers(
+ {
+ setAnkiConnectEnabled: (enabled) => {
+ enabledStates.push(enabled);
+ },
+ clearAnkiHistory: () => {},
+ refreshKnownWords: async () => {},
+ respondFieldGrouping: (choice) => {
+ fieldGroupingChoices.push(choice);
+ },
+ buildKikuMergePreview: async () => ({ ok: true }),
+ getJimakuMediaInfo: () => ({
+ title: 'x',
+ season: null,
+ episode: null,
+ confidence: 'high',
+ filename: 'x.mkv',
+ rawTitle: 'x',
+ }),
+ searchJimakuEntries: async () => ({ ok: true, data: [] }),
+ listJimakuFiles: async () => ({ ok: true, data: [] }),
+ resolveJimakuApiKey: async () => 'token',
+ getCurrentMediaPath: () => '/tmp/a.mkv',
+ isRemoteMediaPath: () => false,
+ downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
+ onDownloadedSubtitle: () => {},
+ },
+ registrar,
+ );
+
+ onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, 'true');
+ onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, true);
+ assert.deepEqual(enabledStates, [true]);
+
+ onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!({}, null);
+ onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!(
+ {},
+ {
+ keepNoteId: 1,
+ deleteNoteId: 2,
+ deleteDuplicate: false,
+ cancelled: false,
+ },
+ );
+ assert.deepEqual(fieldGroupingChoices, [
+ {
+ keepNoteId: 1,
+ deleteNoteId: 2,
+ deleteDuplicate: false,
+ cancelled: false,
+ },
+ ]);
+});
diff --git a/src/core/services/anki-jimaku-ipc.ts b/src/core/services/anki-jimaku-ipc.ts
new file mode 100644
index 0000000..e318750
--- /dev/null
+++ b/src/core/services/anki-jimaku-ipc.ts
@@ -0,0 +1,185 @@
+import { ipcMain } from 'electron';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { createLogger } from '../../logger';
+import {
+ JimakuApiResponse,
+ JimakuDownloadResult,
+ JimakuEntry,
+ JimakuFileEntry,
+ JimakuFilesQuery,
+ JimakuMediaInfo,
+ JimakuSearchQuery,
+ KikuFieldGroupingChoice,
+ KikuMergePreviewRequest,
+ KikuMergePreviewResponse,
+} from '../../types';
+import { IPC_CHANNELS } from '../../shared/ipc/contracts';
+import {
+ parseJimakuDownloadQuery,
+ parseJimakuFilesQuery,
+ parseJimakuSearchQuery,
+ parseKikuFieldGroupingChoice,
+ parseKikuMergePreviewRequest,
+} from '../../shared/ipc/validators';
+
+const logger = createLogger('main:anki-jimaku-ipc');
+
+export interface AnkiJimakuIpcDeps {
+ setAnkiConnectEnabled: (enabled: boolean) => void;
+ clearAnkiHistory: () => void;
+ refreshKnownWords: () => Promise | void;
+ respondFieldGrouping: (choice: KikuFieldGroupingChoice) => void;
+ buildKikuMergePreview: (request: KikuMergePreviewRequest) => Promise;
+ getJimakuMediaInfo: () => JimakuMediaInfo;
+ searchJimakuEntries: (query: JimakuSearchQuery) => Promise>;
+ listJimakuFiles: (query: JimakuFilesQuery) => Promise>;
+ resolveJimakuApiKey: () => Promise;
+ getCurrentMediaPath: () => string | null;
+ isRemoteMediaPath: (mediaPath: string) => boolean;
+ downloadToFile: (
+ url: string,
+ destPath: string,
+ headers: Record