mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix: address config modal review feedback
This commit is contained in:
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ test('settings preload exposes Anki lookup helpers', () => {
|
||||
for (const method of [
|
||||
'getAnkiDeckNames',
|
||||
'getAnkiDeckFieldNames',
|
||||
'getAnkiDeckModelNames',
|
||||
'getAnkiModelNames',
|
||||
'getAnkiModelFieldNames',
|
||||
]) {
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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}`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user