diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 5712839a..03037775 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -185,6 +185,36 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin ); }); +test('buildPluginRuntimeScriptOptParts strips script-option delimiters from string values', () => { + assert.deepEqual( + buildPluginRuntimeScriptOptParts( + { + socketPath: '/tmp/config.sock,subminer-auto_start=no\nother=yes', + binaryPath: '/opt/SubMiner,\nSubMiner.AppImage', + backend: 'x11', + autoStart: true, + autoStartVisibleOverlay: false, + autoStartPauseUntilReady: true, + texthookerEnabled: false, + aniskipEnabled: false, + aniskipButtonKey: 'F8,\nF9', + }, + '/fallback/SubMiner.AppImage', + ), + [ + 'subminer-binary_path=/opt/SubMiner SubMiner.AppImage', + 'subminer-socket_path=/tmp/config.sock subminer-auto_start=no other=yes', + 'subminer-backend=x11', + 'subminer-auto_start=yes', + 'subminer-auto_start_visible_overlay=no', + 'subminer-auto_start_pause_until_ready=yes', + 'subminer-texthooker_enabled=no', + 'subminer-aniskip_enabled=no', + 'subminer-aniskip_button_key=F8 F9', + ], + ); +}); + test('getDefaultSocketPath returns Windows named pipe default', () => { assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket'); }); diff --git a/launcher/types.ts b/launcher/types.ts index dc6df390..742886c1 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -2,12 +2,11 @@ import path from 'node:path'; import os from 'node:os'; import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js'; import { resolveDefaultLogFilePath } from '../src/shared/log-files.js'; -import { getDefaultMpvSocketPath } from '../src/shared/mpv-socket-path.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; export const ROFI_THEME_FILE = 'subminer.rasi'; export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string { - return getDefaultMpvSocketPath(platform); + return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket'; } export const DEFAULT_SOCKET_PATH = getDefaultSocketPath(); diff --git a/package.json b/package.json index 63f90e2d..aab6f929 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", - "test:core:dist": "bun test dist/settings/settings-anki-controls.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", + "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", diff --git a/src/anki-connect.test.ts b/src/anki-connect.test.ts index 14982924..51edeb3d 100644 --- a/src/anki-connect.test.ts +++ b/src/anki-connect.test.ts @@ -123,3 +123,36 @@ test('AnkiConnectClient derives field names from sampled notes in a deck', async params: { notes: [3, 1, 2] }, }); }); + +test('AnkiConnectClient derives model names from sampled notes in a deck', async () => { + const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { + client: { post: (url: string, body: { action: string; params: unknown }) => Promise }; + }; + const calls: Array<{ action: string; params: unknown }> = []; + client.client = { + post: async (_url, body) => { + calls.push({ action: body.action, params: body.params }); + if (body.action === 'findNotes') { + return { data: { result: [5, 4], error: null } }; + } + if (body.action === 'notesInfo') { + return { + data: { + result: [{ modelName: 'Lapis Morph' }, { modelName: 'Kiku' }], + error: null, + }, + }; + } + return { data: { result: [], error: null } }; + }, + }; + + assert.deepEqual(await (client as unknown as AnkiConnectClient).modelNamesForDeck('Mining'), [ + 'Kiku', + 'Lapis Morph', + ]); + assert.deepEqual( + calls.map((call) => call.action), + ['findNotes', 'notesInfo'], + ); +}); diff --git a/src/anki-connect.ts b/src/anki-connect.ts index 1fe4cecf..98853375 100644 --- a/src/anki-connect.ts +++ b/src/anki-connect.ts @@ -177,14 +177,21 @@ export class AnkiConnectClient { : []; } - async fieldNamesForDeck(deckName: string, sampleSize = 100): Promise { + private async noteInfosForDeck( + deckName: string, + sampleSize = 100, + ): Promise[]> { const escapedDeckName = deckName.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const noteIds = await this.findNotes(`deck:"${escapedDeckName}"`, { maxRetries: 0 }); if (noteIds.length === 0) { return []; } - const noteInfos = await this.notesInfo(noteIds.slice(0, sampleSize)); + return this.notesInfo(noteIds.slice(0, sampleSize)); + } + + async fieldNamesForDeck(deckName: string, sampleSize = 100): Promise { + const noteInfos = await this.noteInfosForDeck(deckName, sampleSize); const fields = new Set(); for (const noteInfo of noteInfos) { const noteFields = noteInfo.fields; @@ -198,6 +205,18 @@ export class AnkiConnectClient { return [...fields].sort(); } + async modelNamesForDeck(deckName: string, sampleSize = 100): Promise { + const noteInfos = await this.noteInfosForDeck(deckName, sampleSize); + const modelNames = new Set(); + for (const noteInfo of noteInfos) { + const modelName = noteInfo.modelName; + if (typeof modelName === 'string' && modelName.length > 0) { + modelNames.add(modelName); + } + } + return [...modelNames].sort(); + } + async notesInfo(noteIds: number[]): Promise[]> { const result = await this.invoke('notesInfo', { notes: noteIds }); return (result as Record[]) || []; diff --git a/src/main/runtime/config-settings-runtime.ts b/src/main/runtime/config-settings-runtime.ts index 218839b0..2376ec1a 100644 --- a/src/main/runtime/config-settings-runtime.ts +++ b/src/main/runtime/config-settings-runtime.ts @@ -31,6 +31,7 @@ export interface ConfigSettingsIpcChannels { openConfigSettingsWindow: string; getConfigSettingsAnkiDeckNames: string; getConfigSettingsAnkiDeckFieldNames: string; + getConfigSettingsAnkiDeckModelNames: string; getConfigSettingsAnkiModelNames: string; getConfigSettingsAnkiModelFieldNames: string; } @@ -38,6 +39,7 @@ export interface ConfigSettingsIpcChannels { export interface ConfigSettingsAnkiClient { deckNames(): Promise; fieldNamesForDeck(deckName: string): Promise; + modelNamesForDeck(deckName: string): Promise; modelNames(): Promise; modelFieldNames(modelName: string): Promise; } @@ -211,6 +213,15 @@ export function createConfigSettingsRuntime { + const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : ''; + return normalizedDeckName + ? getAnkiList(draftUrl, (client) => client.modelNamesForDeck(normalizedDeckName)) + : invalidAnkiListResult('Deck name is required.'); + }, + ); deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) => getAnkiList(draftUrl, (client) => client.modelNames()), ); diff --git a/src/preload-settings.test.ts b/src/preload-settings.test.ts index e1d3bd06..a24aa178 100644 --- a/src/preload-settings.test.ts +++ b/src/preload-settings.test.ts @@ -15,6 +15,7 @@ test('settings preload exposes Anki lookup helpers', () => { for (const method of [ 'getAnkiDeckNames', 'getAnkiDeckFieldNames', + 'getAnkiDeckModelNames', 'getAnkiModelNames', 'getAnkiModelFieldNames', ]) { diff --git a/src/preload-settings.ts b/src/preload-settings.ts index 46867782..2d575ce1 100644 --- a/src/preload-settings.ts +++ b/src/preload-settings.ts @@ -14,6 +14,7 @@ const SETTINGS_IPC_CHANNELS = { openWindow: 'config:open-settings-window', getAnkiDeckNames: 'config-settings:anki-deck-names', getAnkiDeckFieldNames: 'config-settings:anki-deck-field-names', + getAnkiDeckModelNames: 'config-settings:anki-deck-model-names', getAnkiModelNames: 'config-settings:anki-model-names', getAnkiModelFieldNames: 'config-settings:anki-model-field-names', } as const; @@ -32,6 +33,11 @@ const configSettingsAPI: ConfigSettingsAPI = { draftUrl?: string, ): Promise => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiDeckFieldNames, deckName, draftUrl), + getAnkiDeckModelNames: ( + deckName: string, + draftUrl?: string, + ): Promise => + ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiDeckModelNames, deckName, draftUrl), getAnkiModelNames: (draftUrl?: string): Promise => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelNames, draftUrl), getAnkiModelFieldNames: ( diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index cb704220..a5243f27 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -533,6 +533,28 @@ test('mpv input forwarding installs local key handling when session binding IPC } }); +test('mpv input forwarding retries a transient keyboard config IPC failure', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + let calls = 0; + + try { + testGlobals.setGetSessionBindings(async () => { + calls += 1; + if (calls === 1) { + throw new Error('transient'); + } + return []; + }); + + await handlers.setupMpvInputForwarding(); + await wait(25); + + assert.equal(calls, 2); + } finally { + testGlobals.restore(); + } +}); + test('session help chord resolver follows remapped session bindings', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 72cff41b..29f04b30 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -953,13 +953,31 @@ export function createKeyboardHandlers( syncKeyboardTokenSelection(); } + async function loadMpvInputForwardingConfigWithRetry(): Promise { + let lastError: unknown = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await loadMpvInputForwardingConfig(); + return; + } catch (error) { + lastError = error; + if (attempt < 2) { + await new Promise((resolve) => { + setTimeout(resolve, 10 * (attempt + 1)); + }); + } + } + } + throw lastError; + } + async function setupMpvInputForwarding(): Promise { installMpvInputForwardingListeners(); syncKeyboardTokenSelection(); let configLoadSettled = false; let configLoadError: unknown = null; - const configLoad = loadMpvInputForwardingConfig().then( + const configLoad = loadMpvInputForwardingConfigWithRetry().then( () => { configLoadSettled = true; }, diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index ebb894e8..50b885ef 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -3,7 +3,11 @@ import test from 'node:test'; import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types'; import { createRendererState } from '../state.js'; -import { createSubtitleSidebarModal, findActiveSubtitleCueIndex } from './subtitle-sidebar.js'; +import { + applySidebarCssDeclarations, + createSubtitleSidebarModal, + findActiveSubtitleCueIndex, +} from './subtitle-sidebar.js'; function createClassList(initialTokens: string[] = []) { const tokens = new Set(initialTokens); @@ -108,6 +112,34 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0); }); +test('applySidebarCssDeclarations clears declarations removed by config reload', () => { + const removed: string[] = []; + const style = { + color: '', + backgroundColor: '', + setProperty(property: string, value: string) { + (this as unknown as Record)[property] = value; + }, + removeProperty(property: string) { + removed.push(property); + delete (this as unknown as Record)[property]; + }, + }; + const target = { style } as unknown as HTMLElement; + + applySidebarCssDeclarations(target, { + color: '#cad3f5', + 'background-color': '#181926', + }); + applySidebarCssDeclarations(target, { + color: '#ffffff', + }); + + assert.equal(style.color, '#ffffff'); + assert.equal(style.backgroundColor, ''); + assert.deepEqual(removed, ['background-color']); +}); + test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback', async () => { const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const previousWindow = globals.window; diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index abccc216..06831c09 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -8,6 +8,7 @@ const CLICK_SEEK_OFFSET_SEC = 0.08; const SNAPSHOT_POLL_INTERVAL_MS = 80; const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240; const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45; +const appliedSidebarCssKeys = new WeakMap>(); function nowForUiTiming(): number { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { @@ -55,22 +56,37 @@ function formatCueTimestamp(seconds: number): string { return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; } -function applySidebarCssDeclarations( +export function applySidebarCssDeclarations( target: HTMLElement, declarations: Record, ): void { const targetStyle = (target as HTMLElement & { style?: CSSStyleDeclaration }).style; if (!targetStyle) return; + const styleTarget = targetStyle as unknown as Record; + const previousKeys = appliedSidebarCssKeys.get(target) ?? new Set(); + const nextKeys = new Set(); + + for (const property of previousKeys) { + if (Object.prototype.hasOwnProperty.call(declarations, property)) continue; + if (property.includes('-')) { + targetStyle.removeProperty(property); + } else { + styleTarget[property] = ''; + } + } + for (const [property, rawValue] of Object.entries(declarations)) { const value = rawValue.trim(); if (value.length === 0) continue; if (property.includes('-')) { targetStyle.setProperty(property, value); - continue; + } else { + styleTarget[property] = value; } - const styleTarget = targetStyle as unknown as Record; - styleTarget[property] = value; + nextKeys.add(property); } + + appliedSidebarCssKeys.set(target, nextKeys); } export function findActiveSubtitleCueIndex( diff --git a/src/settings/settings-anki-controls.test.ts b/src/settings/settings-anki-controls.test.ts index 94ec2143..d03d689d 100644 --- a/src/settings/settings-anki-controls.test.ts +++ b/src/settings/settings-anki-controls.test.ts @@ -2,25 +2,22 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as ankiControls from './settings-anki-controls'; -test('note field model preference prefers exact Kiku over configured model', () => { +test('note field model preference keeps a matching configured model before Kiku fallback', () => { assert.equal( ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'), - 'Kiku', + 'Lapis Morph', ); }); -test('note field model preference ignores configured model case-insensitively', () => { +test('note field model preference matches configured model case-insensitively', () => { assert.equal( ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'), - 'Kiku', + 'Lapis Morph', ); }); test('note field model preference prefers exact Lapis when Kiku is unavailable', () => { - assert.equal( - ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis'], ''), - 'Lapis', - ); + assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis'], ''), 'Lapis'); }); test('note field model preference prefers exact Kiku over exact Lapis', () => { @@ -28,16 +25,13 @@ test('note field model preference prefers exact Kiku over exact Lapis', () => { }); test('note field model preference does not treat partial Kiku matches as Kiku', () => { - assert.equal( - ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Lapis Morph'], 'Lapis Morph'), - '', - ); + assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Mining'], ''), ''); }); -test('note field model preference does not treat partial Lapis matches as Lapis', () => { +test('note field model preference accepts partial Lapis matches', () => { assert.equal( - ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'), - '', + ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], ''), + 'Lapis Morph', ); }); diff --git a/src/settings/settings-anki-controls.ts b/src/settings/settings-anki-controls.ts index 195b469e..cae72dce 100644 --- a/src/settings/settings-anki-controls.ts +++ b/src/settings/settings-anki-controls.ts @@ -9,6 +9,8 @@ const state: { deckFieldNames: Map; deckFieldNamesLoading: Set; deckFieldNamesErrors: Map; + deckModelNames: Map; + deckModelNamesLoading: Map>; modelNames: string[] | null; modelNamesLoading: boolean; modelNamesError: string | null; @@ -25,6 +27,8 @@ const state: { deckFieldNames: new Map(), deckFieldNamesLoading: new Set(), deckFieldNamesErrors: new Map(), + deckModelNames: new Map(), + deckModelNamesLoading: new Map(), modelNames: null, modelNamesLoading: false, modelNamesError: null, @@ -55,16 +59,26 @@ export function initializeAnkiControls(values: Record name.toLowerCase() === normalizedCurrentModelName, + ); + if (currentModel) { + return currentModel; + } + } + const exactKiku = modelNames.find((name) => name.toLowerCase() === 'kiku'); if (exactKiku) { return exactKiku; } - const exactLapis = modelNames.find((name) => name.toLowerCase() === 'lapis'); - if (exactLapis) { - return exactLapis; + const lapis = modelNames.find((name) => name.toLowerCase().includes('lapis')); + if (lapis) { + return lapis; } return ''; @@ -106,6 +120,11 @@ function getDraftAnkiConnectUrl(context: SettingsControlContext): string | undef return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; } +function getDraftAnkiDeckName(context: SettingsControlContext): string { + const value = context.valueForPath('ankiConnect.deck'); + return typeof value === 'string' ? value.trim() : ''; +} + function syncAnkiConnectUrl(draftUrl: string | undefined): void { const nextUrl = draftUrl ?? ''; if (state.ankiConnectUrl === nextUrl) { @@ -116,6 +135,8 @@ function syncAnkiConnectUrl(draftUrl: string | undefined): void { state.deckNamesLoading || state.deckFieldNames.size > 0 || state.deckFieldNamesLoading.size > 0 || + state.deckModelNames.size > 0 || + state.deckModelNamesLoading.size > 0 || state.modelNames !== null || state.modelNamesLoading || state.modelFieldNames.size > 0 || @@ -131,6 +152,8 @@ function syncAnkiConnectUrl(draftUrl: string | undefined): void { state.deckFieldNames.clear(); state.deckFieldNamesLoading.clear(); state.deckFieldNamesErrors.clear(); + state.deckModelNames.clear(); + state.deckModelNamesLoading.clear(); state.modelNames = null; state.modelNamesLoading = false; state.modelNamesError = null; @@ -205,9 +228,85 @@ async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Prom } } -async function loadAnkiModelNames(draftUrl?: string): Promise { +async function loadAnkiDeckModelNames(deckName: string, draftUrl?: string): Promise { syncAnkiConnectUrl(draftUrl); - if (state.modelNames || state.modelNamesLoading) return; + if (!deckName) return []; + const cached = state.deckModelNames.get(deckName); + if (cached) return cached; + const loading = state.deckModelNamesLoading.get(deckName); + if (loading) return loading; + + const requestUrl = state.ankiConnectUrl; + const request = window.configSettingsAPI + .getAnkiDeckModelNames(deckName, draftUrl) + .then((result) => { + if (state.ankiConnectUrl !== requestUrl) return []; + const values = result.ok ? uniqueSorted(result.values) : []; + state.deckModelNames.set(deckName, values); + return values; + }) + .catch(() => { + if (state.ankiConnectUrl === requestUrl) { + state.deckModelNames.set(deckName, []); + } + return []; + }) + .finally(() => { + if (state.ankiConnectUrl === requestUrl) { + state.deckModelNamesLoading.delete(deckName); + requestRender(); + } + }); + + state.deckModelNamesLoading.set(deckName, request); + return request; +} + +function findModelName(modelNames: readonly string[], modelName: string): string { + const normalizedModelName = modelName.trim().toLowerCase(); + return normalizedModelName + ? (modelNames.find((name) => name.toLowerCase() === normalizedModelName) ?? '') + : ''; +} + +async function updatePreferredNoteFieldModelName( + modelNames: readonly string[], + deckName: string, + draftUrl: string | undefined, + requestUrl: string, +): Promise { + const deckModelNames = await loadAnkiDeckModelNames(deckName, draftUrl); + if (state.ankiConnectUrl !== requestUrl || state.noteFieldModelNameManuallySelected) { + return; + } + + let nextModelName = ''; + for (const deckModelName of deckModelNames) { + nextModelName = findModelName(modelNames, deckModelName); + if (nextModelName) break; + } + nextModelName ||= selectPreferredNoteFieldModelName(modelNames, state.noteFieldModelName); + + if (state.noteFieldModelName !== nextModelName) { + state.noteFieldModelName = nextModelName; + requestRender(); + } +} + +async function loadAnkiModelNames(draftUrl?: string, deckName = ''): Promise { + syncAnkiConnectUrl(draftUrl); + if (state.modelNames) { + if (!state.noteFieldModelNameManuallySelected) { + void updatePreferredNoteFieldModelName( + state.modelNames, + deckName, + draftUrl, + state.ankiConnectUrl, + ); + } + return; + } + if (state.modelNamesLoading) return; const requestUrl = state.ankiConnectUrl; state.modelNamesLoading = true; try { @@ -217,10 +316,7 @@ async function loadAnkiModelNames(draftUrl?: string): Promise { state.modelNames = uniqueSorted(result.values); state.modelNamesError = null; if (!state.noteFieldModelNameManuallySelected) { - state.noteFieldModelName = selectPreferredNoteFieldModelName( - state.modelNames, - state.noteFieldModelName, - ); + await updatePreferredNoteFieldModelName(state.modelNames, deckName, draftUrl, requestUrl); } } else { state.modelNames = []; @@ -283,7 +379,7 @@ export function renderAnkiNoteTypeInput( field: ConfigSettingsField, ): HTMLElement { const draftUrl = getDraftAnkiConnectUrl(context); - void loadAnkiModelNames(draftUrl); + void loadAnkiModelNames(draftUrl, getDraftAnkiDeckName(context)); const currentValue = context.valueForField(field); const current = typeof currentValue === 'string' ? currentValue : ''; const select = createElement('select', 'config-input') as HTMLSelectElement; @@ -312,7 +408,7 @@ export function renderAnkiFieldInput( field: ConfigSettingsField, ): HTMLElement { const draftUrl = getDraftAnkiConnectUrl(context); - void loadAnkiModelNames(draftUrl); + void loadAnkiModelNames(draftUrl, getDraftAnkiDeckName(context)); if (state.noteFieldModelName) { void loadAnkiModelFieldNames(state.noteFieldModelName, draftUrl); } @@ -350,7 +446,7 @@ export function renderAnkiFieldInput( export function renderNoteFieldModelPicker(context: SettingsControlContext): HTMLElement { const draftUrl = getDraftAnkiConnectUrl(context); - void loadAnkiModelNames(draftUrl); + void loadAnkiModelNames(draftUrl, getDraftAnkiDeckName(context)); if (state.noteFieldModelName) { void loadAnkiModelFieldNames(state.noteFieldModelName, draftUrl); } diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 3d7c2fcf..6875ab3c 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -103,6 +103,7 @@ export const IPC_CHANNELS = { openConfigSettingsWindow: 'config:open-settings-window', getConfigSettingsAnkiDeckNames: 'config-settings:anki-deck-names', getConfigSettingsAnkiDeckFieldNames: 'config-settings:anki-deck-field-names', + getConfigSettingsAnkiDeckModelNames: 'config-settings:anki-deck-model-names', getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names', getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names', }, diff --git a/src/shared/subminer-plugin-script-opts.ts b/src/shared/subminer-plugin-script-opts.ts index 3ba2eacf..78bbe035 100644 --- a/src/shared/subminer-plugin-script-opts.ts +++ b/src/shared/subminer-plugin-script-opts.ts @@ -16,15 +16,26 @@ function boolScriptOpt(value: boolean): 'yes' | 'no' { return value ? 'yes' : 'no'; } +function sanitizeScriptOptValue(value: string): string { + return value + .replace(/,/g, ' ') + .replace(/[\r\n]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + export function buildSubminerPluginRuntimeScriptOptParts( runtimeConfig: SubminerPluginRuntimeScriptOptConfig, fallbackAppPath: string, ): string[] { - const binaryPath = runtimeConfig.binaryPath?.trim() || fallbackAppPath; + const binaryPath = sanitizeScriptOptValue(runtimeConfig.binaryPath?.trim() || fallbackAppPath); + const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath); + const backend = sanitizeScriptOptValue(runtimeConfig.backend); + const aniskipButtonKey = sanitizeScriptOptValue(runtimeConfig.aniskipButtonKey); return [ `subminer-binary_path=${binaryPath}`, - `subminer-socket_path=${runtimeConfig.socketPath}`, - `subminer-backend=${runtimeConfig.backend}`, + `subminer-socket_path=${socketPath}`, + `subminer-backend=${backend}`, `subminer-auto_start=${boolScriptOpt(runtimeConfig.autoStart)}`, `subminer-auto_start_visible_overlay=${boolScriptOpt(runtimeConfig.autoStartVisibleOverlay)}`, `subminer-auto_start_pause_until_ready=${boolScriptOpt( @@ -32,6 +43,6 @@ export function buildSubminerPluginRuntimeScriptOptParts( )}`, `subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`, `subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`, - `subminer-aniskip_button_key=${runtimeConfig.aniskipButtonKey}`, + `subminer-aniskip_button_key=${aniskipButtonKey}`, ]; } diff --git a/src/types/settings.ts b/src/types/settings.ts index 39b9862b..ce3b7eee 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -90,6 +90,7 @@ export interface ConfigSettingsAPI { openSettingsWindow(): Promise; getAnkiDeckNames(draftUrl?: string): Promise; getAnkiDeckFieldNames(deckName: string, draftUrl?: string): Promise; + getAnkiDeckModelNames(deckName: string, draftUrl?: string): Promise; getAnkiModelNames(draftUrl?: string): Promise; getAnkiModelFieldNames( modelName: string,