fix: address config modal review feedback

This commit is contained in:
2026-05-17 18:23:22 -07:00
parent 7fb1e6d7a5
commit 6f48d4b65b
17 changed files with 333 additions and 43 deletions
+30
View File
@@ -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');
});
+1 -2
View File
@@ -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();
+1 -1
View File
@@ -51,7 +51,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",
+33
View File
@@ -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<unknown> };
};
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'],
);
});
+21 -2
View File
@@ -177,14 +177,21 @@ export class AnkiConnectClient {
: [];
}
async fieldNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
private async noteInfosForDeck(
deckName: string,
sampleSize = 100,
): Promise<Record<string, unknown>[]> {
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<string[]> {
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
const fields = new Set<string>();
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<string[]> {
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
const modelNames = new Set<string>();
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<Record<string, unknown>[]> {
const result = await this.invoke('notesInfo', { notes: noteIds });
return (result as Record<string, unknown>[]) || [];
@@ -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<string[]>;
fieldNamesForDeck(deckName: string): Promise<string[]>;
modelNamesForDeck(deckName: string): Promise<string[]>;
modelNames(): Promise<string[]>;
modelFieldNames(modelName: string): Promise<string[]>;
}
@@ -211,6 +213,15 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
: invalidAnkiListResult('Deck name is required.');
},
);
deps.ipcMain.handle(
deps.ipcChannels.getConfigSettingsAnkiDeckModelNames,
(_event, deckName, draftUrl) => {
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()),
);
+1
View File
@@ -15,6 +15,7 @@ test('settings preload exposes Anki lookup helpers', () => {
for (const method of [
'getAnkiDeckNames',
'getAnkiDeckFieldNames',
'getAnkiDeckModelNames',
'getAnkiModelNames',
'getAnkiModelFieldNames',
]) {
+6
View File
@@ -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<ConfigSettingsAnkiListResult> =>
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiDeckFieldNames, deckName, draftUrl),
getAnkiDeckModelNames: (
deckName: string,
draftUrl?: string,
): Promise<ConfigSettingsAnkiListResult> =>
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiDeckModelNames, deckName, draftUrl),
getAnkiModelNames: (draftUrl?: string): Promise<ConfigSettingsAnkiListResult> =>
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelNames, draftUrl),
getAnkiModelFieldNames: (
+22
View File
@@ -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();
+19 -1
View File
@@ -953,13 +953,31 @@ export function createKeyboardHandlers(
syncKeyboardTokenSelection();
}
async function loadMpvInputForwardingConfigWithRetry(): Promise<void> {
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<void>((resolve) => {
setTimeout(resolve, 10 * (attempt + 1));
});
}
}
}
throw lastError;
}
async function setupMpvInputForwarding(): Promise<void> {
installMpvInputForwardingListeners();
syncKeyboardTokenSelection();
let configLoadSettled = false;
let configLoadError: unknown = null;
const configLoad = loadMpvInputForwardingConfig().then(
const configLoad = loadMpvInputForwardingConfigWithRetry().then(
() => {
configLoadSettled = true;
},
+33 -1
View File
@@ -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<string, string>)[property] = value;
},
removeProperty(property: string) {
removed.push(property);
delete (this as unknown as Record<string, string>)[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;
+20 -4
View File
@@ -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<HTMLElement, Set<string>>();
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<string, string>,
): void {
const targetStyle = (target as HTMLElement & { style?: CSSStyleDeclaration }).style;
if (!targetStyle) return;
const styleTarget = targetStyle as unknown as Record<string, string>;
const previousKeys = appliedSidebarCssKeys.get(target) ?? new Set<string>();
const nextKeys = new Set<string>();
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<string, string>;
styleTarget[property] = value;
nextKeys.add(property);
}
appliedSidebarCssKeys.set(target, nextKeys);
}
export function findActiveSubtitleCueIndex(
+9 -15
View File
@@ -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',
);
});
+109 -13
View File
@@ -9,6 +9,8 @@ const state: {
deckFieldNames: Map<string, string[]>;
deckFieldNamesLoading: Set<string>;
deckFieldNamesErrors: Map<string, string>;
deckModelNames: Map<string, string[]>;
deckModelNamesLoading: Map<string, Promise<string[]>>;
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<string, ConfigSettingsSnap
export function selectPreferredNoteFieldModelName(
modelNames: readonly string[],
_currentModelName = '',
currentModelName = '',
): string {
const normalizedCurrentModelName = currentModelName.trim().toLowerCase();
if (normalizedCurrentModelName) {
const currentModel = modelNames.find(
(name) => 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<void> {
async function loadAnkiDeckModelNames(deckName: string, draftUrl?: string): Promise<string[]> {
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<void> {
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<void> {
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<void> {
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);
}
+1
View File
@@ -104,6 +104,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',
},
+15 -4
View File
@@ -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}`,
];
}
+1
View File
@@ -90,6 +90,7 @@ export interface ConfigSettingsAPI {
openSettingsWindow(): Promise<boolean>;
getAnkiDeckNames(draftUrl?: string): Promise<ConfigSettingsAnkiListResult>;
getAnkiDeckFieldNames(deckName: string, draftUrl?: string): Promise<ConfigSettingsAnkiListResult>;
getAnkiDeckModelNames(deckName: string, draftUrl?: string): Promise<ConfigSettingsAnkiListResult>;
getAnkiModelNames(draftUrl?: string): Promise<ConfigSettingsAnkiListResult>;
getAnkiModelFieldNames(
modelName: string,