feat(dictionary): add Hachidori backend support (#266)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-24 21:08:18 -07:00
1 parent e690ffba07
commit cbaa4626e1
143 files changed
+5564 -305

No files matched your search

@@ -179,6 +179,45 @@ const konosuba = { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba'
const towerOfGod = { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God' };
const eminence = { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence' };
test('replacement-capable imports retain the installed dictionary when an update fails', async () => {
for (const succeeds of [true, false]) {
let imported = false;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath: makeTempDir(),
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 7,
mediaTitle: 'Frieren',
entryCount: 100,
fromCache: true,
updatedAt: 1000,
}),
buildMergedDictionary: async () => ({
zipPath: '/tmp/replacement.zip',
revision: 'new',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 100,
}),
getYomitanDictionaryInfo: async () => [
{ title: 'SubMiner Character Dictionary', revision: 'old' },
],
dictionaryImportReplacesExisting: () => true,
importYomitanDictionary: async () => {
imported = true;
return succeeds;
},
deleteYomitanDictionary: async () => {
assert.fail('must keep the old dictionary until replacement succeeds');
},
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
});
if (succeeds) await runtime.runSyncNow();
else await assert.rejects(runtime.runSyncNow(), /Failed to import dictionary ZIP/);
assert.equal(imported, true);
}
});
test('character dictionary manager snapshots, reorders, and removes MRU entries', () => {
const userDataPath = makeTempDir();
writeState(userDataPath, {
@@ -80,6 +80,7 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
waitForYomitanMutationReady?: () => Promise<void>;
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
dictionaryImportReplacesExisting?: () => boolean;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
upsertYomitanDictionarySettings: (
dictionaryTitle: string,
@@ -669,7 +670,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
const importTimeoutMs = resolveImportTimeoutMs(
merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'),
);
if (existing !== null) {
if (existing !== null && deps.dictionaryImportReplacesExisting?.() !== true) {
await withTimeout(
`deleteYomitanDictionary(${dictionaryTitle})`,
deps.deleteYomitanDictionary(dictionaryTitle),
@@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
@@ -108,6 +109,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
@@ -72,6 +72,7 @@ function buildContextDeps(overrides: Partial<MainDeps> = {}) {
runUpdateCommand: noopAsync,
runEnsureLinuxRuntimePluginAssetsCommand: noopAsync,
runYoutubePlaybackFlow: noopAsync,
openHachidoriSettings: noop,
openYomitanSettings: noop,
openConfigSettingsWindow: noop,
openSyncUiWindow: noop,
@@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
@@ -145,6 +146,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: () => deps.openYomitanSettings(),
openHachidoriSettings: () => deps.openHachidoriSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
@@ -52,6 +52,7 @@ function createDeps() {
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
+2
View File
@@ -52,6 +52,7 @@ export type CliCommandContextFactoryDeps = {
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openYoutubeBrowserWindow: () => void;
@@ -135,6 +136,7 @@ export function createCliCommandContext(
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
@@ -6,6 +6,11 @@ import path from 'node:path';
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
import type { CliArgs } from '../../cli/args';
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
import {
createDefaultSetupState,
getSetupStatePath,
readSetupState,
} from '../../shared/setup-state';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
@@ -30,6 +35,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
youtubeBrowser: false,
@@ -763,3 +769,151 @@ test('setup service reports failed legacy mpv plugin trash paths', async () => {
]);
});
});
test('switching to Hachidori requires its own dictionaries and persists backend readiness', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
let dictionaryCount = 0;
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => dictionaryCount,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
});
const initial = await hachidori.ensureSetupStateInitialized();
assert.equal(initial.dictionaryBackend, 'hachidori');
assert.equal(initial.state.dictionaryBackend, 'hachidori');
assert.equal(initial.canFinish, false);
assert.equal(initial.externalYomitanConfigured, false);
assert.equal(initial.state.status, 'incomplete');
dictionaryCount = 1;
const completed = await hachidori.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.dictionaryBackend, 'hachidori');
assert.equal(completed.state.lastSeenYomitanDictionaryCount, 1);
assert.equal(hachidori.isSetupCompleted(), true);
assert.deepEqual(completed.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
// Switching back never repeats setup for a backend that already finished.
const yomitanAgain = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
const restored = await yomitanAgain.ensureSetupStateInitialized();
assert.equal(restored.state.status, 'completed');
assert.equal(restored.state.dictionaryBackend, 'yomitan');
assert.equal(yomitanAgain.isSetupCompleted(), true);
assert.equal(readSetupState(getSetupStatePath(configDir))?.dictionaryBackend, 'yomitan');
assert.deepEqual(restored.state.completedDictionaryBackends, ['hachidori', 'yomitan']);
});
});
test('reopening setup for legacy plugin cleanup preserves both backend completions', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
detectLegacyMpvPluginCandidates: () => [
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
],
});
await yomitan.ensureSetupStateInitialized();
const reopened = await yomitan.markSetupInProgress();
assert.equal(reopened.state.status, 'in_progress');
assert.deepEqual(reopened.state.completedDictionaryBackends, ['yomitan']);
assert.equal(yomitan.isSetupCompleted(), false);
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
const switched = await hachidori.ensureSetupStateInitialized();
assert.deepEqual(switched.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
const restored = await yomitan.getSetupStatus();
assert.equal(restored.state.status, 'completed');
});
});
test('a legacy completed Yomitan state file survives a first Hachidori run', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
fs.writeFileSync(
getSetupStatePath(configDir),
JSON.stringify({ ...createDefaultSetupState(), status: 'completed', completedAt: 'x' }),
);
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
});
const initial = await hachidori.ensureSetupStateInitialized();
assert.equal(initial.state.status, 'incomplete');
const stored = readSetupState(getSetupStatePath(configDir));
assert.equal(stored?.dictionaryBackend, 'hachidori');
assert.deepEqual(stored?.completedDictionaryBackends, ['yomitan']);
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
});
});
test('Hachidori setup gates on the linked host instead of local dictionaries', async () => {
await withTempDir(async (dir) => {
fs.writeFileSync(path.join(dir, 'config.json'), '{}');
let host: import('../../shared/hachidori-sharing').HachidoriHostStatus = {
kind: 'connected',
address: 'ws://127.0.0.1:8771/link',
name: 'Docker',
dictionaryCount: 2,
capabilities: [],
};
const service = createFirstRunSetupService({
configDir: dir,
getDictionaryBackend: () => 'hachidori',
getHachidoriHostStatus: async () => host,
getYomitanDictionaryCount: async () => 7,
detectPluginInstalled: () => false,
});
let snapshot = await service.getSetupStatus();
assert.equal(snapshot.dictionaryCount, 2);
assert.equal(snapshot.canFinish, true);
assert.equal((await service.markSetupCompleted()).state.status, 'completed');
// The link is still connecting at startup; that must not undo a finished setup.
host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Connecting' };
snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.canFinish, false);
assert.equal(snapshot.dictionaryCount, 0);
assert.equal(snapshot.state.status, 'completed');
assert.equal(service.isSetupCompleted(), true);
host = {
kind: 'connected',
address: 'ws://127.0.0.1:8771/link',
name: 'Docker',
dictionaryCount: 0,
capabilities: [],
};
assert.equal((await service.getSetupStatus()).canFinish, false);
// A reachable host with no dictionaries does reopen setup.
assert.equal((await service.ensureSetupStateInitialized()).state.status, 'incomplete');
assert.notEqual((await service.markSetupCompleted()).state.status, 'completed');
host = { kind: 'local' };
assert.equal((await service.getSetupStatus()).dictionaryCount, 7);
assert.equal((await service.getSetupStatus()).canFinish, true);
});
});
+107 -7
View File
@@ -1,7 +1,10 @@
import fs from 'node:fs';
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
import {
createDefaultSetupState,
getDefaultConfigFilePaths,
getSetupStateDictionaryBackend,
hasCompletedSetupForBackend,
getSetupStatePath,
isSetupCompleted,
readSetupState,
@@ -11,6 +14,7 @@ import {
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
import type { DictionaryBackend } from '../../types/config';
import type {
InstalledFirstRunPluginCandidate,
LegacyMpvPluginRemovalResult,
@@ -28,6 +32,8 @@ export interface SetupWindowsMpvShortcutSnapshot {
}
export interface SetupStatusSnapshot {
dictionaryBackend: DictionaryBackend;
hachidoriHost?: HachidoriHostStatus;
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
@@ -72,6 +78,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.togglePrimarySubtitleBar ||
args.launchMpv ||
args.yomitan ||
args.hachidori ||
args.settings ||
args.show ||
args.hide ||
@@ -205,6 +212,8 @@ function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnap
}
export function getFirstRunSetupCompletionMessage(snapshot: {
dictionaryBackend?: DictionaryBackend;
hachidoriHost?: HachidoriHostStatus;
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
@@ -213,8 +222,18 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
if (!snapshot.configReady) {
return 'Create or provide the config file before finishing setup.';
}
if (
snapshot.hachidoriHost?.kind === 'disconnected' ||
snapshot.hachidoriHost?.kind === 'unavailable'
) {
return snapshot.hachidoriHost.message;
}
if (snapshot.hachidoriHost?.kind === 'connected' && snapshot.dictionaryCount < 1) {
return 'Install at least one dictionary on the linked Hachidori host, then refresh status.';
}
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
return 'Install at least one Yomitan dictionary before finishing setup.';
const name = snapshot.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
return `Install at least one ${name} dictionary before finishing setup.`;
}
return null;
}
@@ -222,15 +241,38 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
async function resolveYomitanSetupStatus(deps: {
configFilePaths: { jsoncPath: string; jsonPath: string };
getYomitanDictionaryCount: () => Promise<number>;
getDictionaryBackend?: () => DictionaryBackend;
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
isExternalYomitanConfigured?: () => boolean;
}): Promise<{
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
hachidoriHost?: HachidoriHostStatus;
}> {
const configReady =
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
const externalYomitanConfigured =
deps.getDictionaryBackend?.() !== 'hachidori' &&
(deps.isExternalYomitanConfigured?.() ?? false);
const hachidoriHost =
deps.getDictionaryBackend?.() === 'hachidori'
? await deps.getHachidoriHostStatus?.().catch(
(error: unknown): HachidoriHostStatus => ({
kind: 'unavailable',
message: error instanceof Error ? error.message : 'Hachidori is unavailable.',
}),
)
: undefined;
if (hachidoriHost && hachidoriHost.kind !== 'local') {
return {
configReady,
externalYomitanConfigured: false,
hachidoriHost,
dictionaryCount: hachidoriHost.kind === 'connected' ? hachidoriHost.dictionaryCount : 0,
};
}
if (configReady && externalYomitanConfigured) {
return {
@@ -242,6 +284,7 @@ async function resolveYomitanSetupStatus(deps: {
return {
configReady,
hachidoriHost,
dictionaryCount: await deps.getYomitanDictionaryCount(),
externalYomitanConfigured,
};
@@ -251,6 +294,8 @@ export function createFirstRunSetupService(deps: {
platform?: NodeJS.Platform;
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
getDictionaryBackend?: () => DictionaryBackend;
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>;
detectLegacyMpvPluginCandidates?: () =>
@@ -283,8 +328,47 @@ export function createFirstRunSetupService(deps: {
const isWindows = (deps.platform ?? process.platform) === 'win32';
let completed = false;
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
const getDictionaryBackend = () => deps.getDictionaryBackend?.() ?? 'yomitan';
const readStoredState = (): SetupState =>
readSetupState(setupStatePath) ?? createDefaultSetupState();
// The file records one backend's status at a time. Project it onto the active
// backend: a backend that finished before stays completed, any other stays
// incomplete until its own dictionaries are ready.
const projectState = (stored: SetupState): SetupState => {
const backend = getDictionaryBackend();
const finishedBefore = hasCompletedSetupForBackend(stored, backend);
// Legacy files carry their completion only as the recorded status; keep it.
const storedBackend = getSetupStateDictionaryBackend(stored);
const completedDictionaryBackends = [
...new Set([
...(stored.completedDictionaryBackends ?? []),
...(stored.status === 'completed' ? [storedBackend] : []),
]),
];
if (storedBackend === backend) return { ...stored, completedDictionaryBackends };
return {
...stored,
dictionaryBackend: backend,
completedDictionaryBackends,
status: finishedBefore ? 'completed' : 'incomplete',
completedAt: finishedBefore ? stored.completedAt : null,
completionSource: finishedBefore ? (stored.completionSource ?? 'user') : null,
yomitanSetupMode: finishedBefore ? 'internal' : null,
lastSeenYomitanDictionaryCount: 0,
};
};
const readState = (): SetupState => projectState(readStoredState());
const writeState = (state: SetupState): SetupState => {
const backend = getDictionaryBackend();
const others = (state.completedDictionaryBackends ?? []).filter((entry) => entry !== backend);
state = {
...state,
dictionaryBackend: backend,
completedDictionaryBackends:
state.status === 'completed'
? [...others, backend]
: (state.completedDictionaryBackends ?? []),
};
writeSetupState(setupStatePath, state);
completed = state.status === 'completed';
deps.onStateChanged?.(state);
@@ -292,10 +376,12 @@ export function createFirstRunSetupService(deps: {
};
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const { configReady, dictionaryCount, externalYomitanConfigured } =
const { configReady, dictionaryCount, externalYomitanConfigured, hachidoriHost } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
getDictionaryBackend,
getHachidoriHostStatus: deps.getHachidoriHostStatus,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
@@ -314,6 +400,8 @@ export function createFirstRunSetupService(deps: {
installedWindowsMpvShortcuts,
);
return {
dictionaryBackend: getDictionaryBackend(),
hachidoriHost,
configReady,
dictionaryCount,
canFinish: isYomitanSetupSatisfied({
@@ -353,11 +441,19 @@ export function createFirstRunSetupService(deps: {
return {
ensureSetupStateInitialized: async () => {
const state = readState();
const { configReady, dictionaryCount, externalYomitanConfigured } =
const stored = readStoredState();
// Persist the active backend stamp so the launcher can tell which backend
// the running app gates playback on.
const state =
getSetupStateDictionaryBackend(stored) === getDictionaryBackend()
? stored
: writeState(projectState(stored));
const { configReady, dictionaryCount, externalYomitanConfigured, hachidoriHost } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
getDictionaryBackend,
getHachidoriHostStatus: deps.getHachidoriHostStatus,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const canFinish = isYomitanSetupSatisfied({
@@ -365,7 +461,11 @@ export function createFirstRunSetupService(deps: {
dictionaryCount,
externalYomitanConfigured,
});
if (isSetupCompleted(state) && canFinish) {
// A linked host is usually still connecting at startup, so an unreachable host
// says nothing about its dictionaries; only a reachable empty host reopens setup.
const hostUnreachable =
hachidoriHost?.kind === 'disconnected' || hachidoriHost?.kind === 'unavailable';
if (isSetupCompleted(state) && (canFinish || (configReady && hostUnreachable))) {
completed = true;
return refreshWithState(state);
}
@@ -727,3 +727,51 @@ test('closing completed first-run setup quits app when completion policy allows
assert.deepEqual(calls, ['set', 'clear', 'quit']);
});
test('Hachidori setup names the active dictionary backend', () => {
const html = buildFirstRunSetupHtml({
dictionaryBackend: 'hachidori',
configReady: true,
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
assert.match(html, /Hachidori dictionaries/);
assert.match(html, /Open Hachidori Settings/);
assert.match(html, /Install at least one Hachidori dictionary/);
assert.doesNotMatch(html, /Open Yomitan Settings/);
assert.match(html, /Link host/);
assert.match(html, /<details class="external-host">/);
assert.match(html, /Use an external dictionary host/);
assert.match(html, /keep the browser and its sharing relay running/);
assert.match(html, /You can close its browser management page/);
});
test('setup parses link and unlink actions without losing the host address', () => {
const address = 'ws://127.0.0.1:38771/link';
assert.deepEqual(
parseFirstRunSetupSubmissionUrl(
'subminer://first-run-setup?action=link-hachidori-host&address=' +
encodeURIComponent(address),
),
{ action: 'link-hachidori-host', address },
);
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=unlink-hachidori-host'),
{ action: 'unlink-hachidori-host' },
);
});
+90 -23
View File
@@ -1,3 +1,5 @@
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
import type { DictionaryBackend } from '../../types/config';
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
@@ -18,25 +20,31 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
close: () => void;
};
export type FirstRunSetupAction =
| 'configure-mpv-executable-path'
| 'remove-legacy-plugin'
| 'configure-windows-mpv-shortcuts'
| 'install-bun'
| 'install-command-line-launcher'
| 'open-yomitan-settings'
| 'open-config-settings'
| 'refresh'
| 'finish';
export type FirstRunSetupSubmission =
| { action: 'configure-mpv-executable-path'; mpvExecutablePath: string }
| {
action: 'configure-windows-mpv-shortcuts';
startMenuEnabled: boolean;
desktopEnabled: boolean;
}
| { action: 'link-hachidori-host'; address: string }
| {
action:
| 'unlink-hachidori-host'
| 'remove-legacy-plugin'
| 'install-bun'
| 'install-command-line-launcher'
| 'open-yomitan-settings'
| 'open-config-settings'
| 'refresh'
| 'finish';
};
export interface FirstRunSetupSubmission {
action: FirstRunSetupAction;
mpvExecutablePath?: string;
startMenuEnabled?: boolean;
desktopEnabled?: boolean;
}
export type FirstRunSetupAction = FirstRunSetupSubmission['action'];
export interface FirstRunSetupHtmlModel {
dictionaryBackend?: DictionaryBackend;
hachidoriHost?: HachidoriHostStatus;
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
@@ -251,9 +259,47 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>`
: '';
const yomitanMeta = model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
: `${model.dictionaryCount} installed`;
const dictionaryName = model.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
const host = model.hachidoriHost;
const linked = host?.kind === 'connected' || host?.kind === 'disconnected';
const hostAddress = linked ? host.address : '';
const hostCard =
model.dictionaryBackend === 'hachidori'
? `
<div class="card block">
<strong>Dictionary source</strong>
<p class="meta">Import dictionaries through Hachidori Settings to keep them in SubMiner, or connect to an existing library below.</p>
<details class="external-host"${linked ? ' open' : ''}>
<summary>Use an external dictionary host</summary>
<p class="meta">Connect to Hachidori in another app, browser, or Docker container to use the dictionaries already installed there. You won't need to import a second copy into SubMiner.</p>
<p class="meta">Dictionaries and their settings are shared. You still mine cards in SubMiner, using its own Anki settings, audio, and screenshots.</p>
<ul class="meta host-requirements">
<li>Browser: keep the browser and its sharing relay running. If the relay runs through Anki, keep Anki open too.</li>
<li>Desktop app: keep the app sharing your dictionaries and any required relay running.</li>
<li>Docker: keep the dictionary container running. You can close its browser management page.</li>
</ul>
<p class="meta">If that app or container stops or loses its connection, dictionary lookups will be unavailable until it reconnects.</p>
<form class="path-form" onsubmit="event.preventDefault(); const address = document.getElementById('hachidori-host-address').value; window.location.href='subminer://first-run-setup?action=link-hachidori-host&amp;address='+encodeURIComponent(address)">
<label for="hachidori-host-address">Host address</label>
<input id="hachidori-host-address" type="text" value="${escapeHtml(hostAddress)}" placeholder="127.0.0.1:8771 or ws://host:8771/link" required />
<div class="meta">Use the address from Hachidori's Sharing settings or your container's WebSocket sharing address, rather than its management page URL.</div>
<div class="inline-actions">
<button type="submit">${linked ? 'Change host' : 'Link host'}</button>
${linked ? `<button type="button" class="ghost" onclick="window.location.href='subminer://first-run-setup?action=unlink-hachidori-host'">Unlink and use local dictionaries</button>` : ''}
</div>
</form>
${host?.kind === 'disconnected' || host?.kind === 'unavailable' ? `<p class="meta">${escapeHtml(host.message)}</p>` : ''}
</details>
</div>`
: '';
const yomitanMeta =
host?.kind === 'connected'
? `${host.dictionaryCount} ${host.dictionaryCount === 1 ? 'dictionary' : 'dictionaries'} from ${host.name} at ${host.address}`
: host?.kind === 'disconnected'
? `Host unavailable: ${host.address}`
: model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
: `${model.dictionaryCount} installed`;
const yomitanBadgeLabel = model.externalYomitanConfigured
? 'External'
: model.dictionaryCount >= 1
@@ -270,8 +316,8 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.canFinish
? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
: `Finish stays unlocked once ${dictionaryName} reports at least one installed dictionary.`
: `Finish stays locked until ${dictionaryName} reports at least one installed dictionary.`;
return `<!doctype html>
<html>
@@ -340,6 +386,21 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
color: var(--muted);
font-size: 12px;
}
.external-host summary {
cursor: pointer;
color: var(--blue);
font-weight: 700;
}
.external-host[open] summary {
margin-bottom: 10px;
}
.host-requirements {
padding-left: 20px;
margin: 0 0 12px;
}
.host-requirements li + li {
margin-top: 4px;
}
.shortcut-form {
display: grid;
gap: 8px;
@@ -468,17 +529,18 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
<div class="card">
<div>
<strong>Yomitan dictionaries</strong>
<strong>${dictionaryName} dictionaries</strong>
<div class="meta">${escapeHtml(yomitanMeta)}</div>
</div>
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
</div>
${hostCard}
${mpvExecutablePathCard}
${windowsShortcutCard}
${renderCommandLineLauncherSection(model.commandLineLauncher)}
${legacyPluginCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open ${dictionaryName} Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
@@ -497,6 +559,8 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
const parsed = new URL(rawUrl);
const action = parsed.searchParams.get('action');
if (
action !== 'link-hachidori-host' &&
action !== 'unlink-hachidori-host' &&
action !== 'configure-mpv-executable-path' &&
action !== 'remove-legacy-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
@@ -509,6 +573,9 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
) {
return null;
}
if (action === 'link-hachidori-host') {
return { action, address: parsed.searchParams.get('address')?.trim() ?? '' };
}
if (action === 'configure-mpv-executable-path') {
return {
action,
+89
View File
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import { applyLoginShellPath, mergePathValues, readLoginShellPath } from './login-shell-path';
const wrap = (value: string) =>
`motd noise\n__SUBMINER_LOGIN_PATH__${value}\n__SUBMINER_LOGIN_PATH__`;
test('readLoginShellPath extracts PATH between markers, ignoring rc-file output', async () => {
let invoked: { shell: string; args: string[] } | null = null;
const value = await readLoginShellPath({
env: { SHELL: '/bin/zsh' },
runShell: async (shell, args) => {
invoked = { shell, args };
return wrap('/Users/me/.local/bin:/usr/bin');
},
});
assert.equal(value, '/Users/me/.local/bin:/usr/bin');
assert.equal(invoked!.shell, '/bin/zsh');
assert.equal(invoked!.args[0], '-ilc');
});
test('readLoginShellPath returns null when markers are missing', async () => {
const value = await readLoginShellPath({ env: {}, shell: '/bin/sh', runShell: async () => '' });
assert.equal(value, null);
});
test('readLoginShellPath rejects relative explicit shells before execution', async () => {
for (const shell of ['zsh', './zsh', '']) {
await assert.rejects(
readLoginShellPath({
env: { SHELL: '/bin/zsh' },
shell,
runShell: async () => assert.fail('Invalid shell must not execute'),
}),
/Login shell must be an absolute path/,
);
}
});
test('readLoginShellPath falls back through absolute default shells', async () => {
const originalUserInfo = os.userInfo;
try {
for (const shell of ['/bin/bash', 'bash', '', null]) {
Object.defineProperty(os, 'userInfo', {
value: () => ({ shell, username: 'test', uid: 1000, gid: 1000, homedir: '/Users/test' }),
});
await readLoginShellPath({
env: { SHELL: './zsh' },
runShell: async (selectedShell) => {
assert.equal(selectedShell, shell === '/bin/bash' ? '/bin/bash' : '/bin/zsh');
return wrap('/usr/bin');
},
});
}
Object.defineProperty(os, 'userInfo', {
value: () => {
throw new Error('User lookup failed');
},
});
await readLoginShellPath({
env: { SHELL: 'zsh' },
runShell: async (shell) => {
assert.equal(shell, '/bin/zsh');
return wrap('/usr/bin');
},
});
} finally {
os.userInfo = originalUserInfo;
}
});
test('mergePathValues puts login entries first and keeps process-only entries', () => {
assert.equal(
mergePathValues('/Users/me/.local/bin:/usr/bin:/bin', '/usr/bin:/bin:/usr/sbin:/sbin'),
'/Users/me/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
);
});
test('applyLoginShellPath updates env.PATH so launcher detection sees shell dirs', async () => {
const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin' };
const applied = await applyLoginShellPath({
env,
shell: '/bin/zsh',
runShell: async () => wrap('/Users/me/.local/bin:/usr/bin'),
});
assert.equal(applied, true);
assert.equal(env.PATH, '/Users/me/.local/bin:/usr/bin:/bin');
});
+72
View File
@@ -0,0 +1,72 @@
import { execFile } from 'node:child_process';
import os from 'node:os';
import { isAbsolute } from 'node:path';
const MARKER = '__SUBMINER_LOGIN_PATH__';
const DEFAULT_TIMEOUT_MS = 5000;
export type RunShell = (shell: string, args: string[], timeoutMs: number) => Promise<string>;
type LoginShellPathOptions = {
env: NodeJS.ProcessEnv;
shell?: string;
runShell?: RunShell;
timeoutMs?: number;
};
const runShellDefault: RunShell = (shell, args, timeoutMs) =>
new Promise((resolve, reject) => {
const child = execFile(
shell,
args,
{
timeout: timeoutMs,
encoding: 'utf8',
env: { ...process.env, DISABLE_AUTO_UPDATE: 'true' },
},
(error, stdout) => (error ? reject(error) : resolve(stdout)),
);
child.stdin?.end();
});
function defaultShell(env: NodeJS.ProcessEnv): string {
if (env.SHELL && isAbsolute(env.SHELL)) return env.SHELL;
try {
const shell = os.userInfo().shell;
return shell && isAbsolute(shell) ? shell : '/bin/zsh';
} catch {
return '/bin/zsh';
}
}
/**
* Reads PATH as the user's interactive login shell sees it. macOS GUI apps inherit
* launchd's minimal PATH, so dirs added in ~/.zshrc and friends are otherwise invisible.
*/
export async function readLoginShellPath(options: LoginShellPathOptions): Promise<string | null> {
const shell = options.shell ?? defaultShell(options.env);
if (!isAbsolute(shell)) throw new Error('Login shell must be an absolute path');
const run = options.runShell ?? runShellDefault;
// printenv keeps this shell-agnostic (fish exposes $PATH as a list).
const command = `printf '%s' '${MARKER}'; /usr/bin/printenv PATH; printf '%s' '${MARKER}'`;
const stdout = await run(shell, ['-ilc', command], options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const value = stdout.split(MARKER)[1]?.trim();
return value || null;
}
/** Puts login-shell entries first (terminal precedence), keeping any process-only entries after. */
export function mergePathValues(loginPath: string, currentPath: string | undefined): string {
const merged: string[] = [];
for (const entry of [...loginPath.split(':'), ...(currentPath ?? '').split(':')]) {
if (entry && !merged.includes(entry)) merged.push(entry);
}
return merged.join(':');
}
/** Merges the login-shell PATH into `options.env.PATH`. Resolves false when it couldn't be read. */
export async function applyLoginShellPath(options: LoginShellPathOptions): Promise<boolean> {
const loginPath = await readLoginShellPath(options);
if (!loginPath) return false;
options.env.PATH = mergePathValues(loginPath, options.env.PATH);
return true;
}
+13 -6
View File
@@ -372,12 +372,19 @@ test('Windows stages a new runtime version while the prior Bun executable is run
await exited;
}
}
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.equal(fs.existsSync(path.dirname(first.bunPath)), false);
// Windows can keep bun.exe locked briefly after the exit event; cleanup
// skips locked runtimes, so retry the way the next launch would.
const firstDirectory = path.dirname(first.bunPath);
for (let attempt = 0; attempt < 30; attempt += 1) {
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
if (!fs.existsSync(firstDirectory)) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.equal(fs.existsSync(firstDirectory), false);
assert.ok(fs.existsSync(expectedSecond.bunPath));
});
+105 -3
View File
@@ -95,9 +95,11 @@ async function startActiveMediaTimingReview(
decisionTimeoutMs?: number;
generateWaveform?: () => Promise<number[]>;
play?: () => Promise<void>;
paused?: boolean;
} = {},
) {
const previewCalls: Array<[number, number]> = [];
const commands: Array<Array<string | number>> = [];
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve;
@@ -106,8 +108,9 @@ async function startActiveMediaTimingReview(
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
send: () => undefined,
requestProperty: async (name) =>
name === 'duration' ? 100 : name === 'pause' ? (options.paused ?? true) : null,
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
@@ -138,7 +141,7 @@ async function startActiveMediaTimingReview(
maxMediaDuration: options.maxMediaDuration ?? 30,
});
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
return { runtime, payload: await openedPayload, pendingDecision, previewCalls, commands };
}
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
@@ -646,6 +649,105 @@ test('collectMediaTimingContextLines falls back to played history when no cues a
assert.deepEqual(context.next, []);
});
test('media timing review keeps an already-paused video paused when nothing asks to resume', async () => {
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
});
test('media timing review holds overlay resume requests until the review closes', async () => {
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
assert.equal(runtime.deferPlaybackResume(), true);
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
assert.equal(runtime.deferPlaybackResume(), false);
});
for (const paused of [true, false]) {
test(`media timing review stays paused after an overlay pause request (paused before: ${paused})`, async () => {
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview({
paused,
});
assert.equal(runtime.deferPlaybackResume(), true);
runtime.cancelPlaybackResume();
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
});
}
test('media timing review honors an overlay pause that arrives while setup reads the pause state', async () => {
const commands: Array<Array<string | number>> = [];
const pauseReads: Array<(paused: boolean) => void> = [];
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: (name) =>
name === 'pause'
? new Promise((resolve) => pauseReads.push(resolve))
: Promise.resolve(name === 'duration' ? 100 : null),
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
queueMicrotask(() => {
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
});
return true;
},
showStatus: () => undefined,
});
const request = {
kind: 'sentence' as const,
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
};
const first = runtime.requestReview(request);
runtime.cancelPlaybackResume();
// mpv answered the read before the overlay's pause reached it.
pauseReads[0]!(false);
await first;
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
commands.length = 0;
const second = runtime.requestReview(request);
pauseReads[1]!(false);
await second;
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
});
test('media timing review watchdog falls back when the renderer stops responding', async () => {
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
+42 -2
View File
@@ -280,6 +280,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
let active: ActiveReview | null = null;
let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null;
let resumeDeferred = false;
/** Set when the overlay pauses during the current request, even before its setup reads the pause state. */
let restoreCancelled = false;
function restorePendingPlayback(): void {
const mpvClient = pendingPauseRestore;
@@ -289,6 +292,37 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
}
function resumeDeferredPlayback(): void {
if (!resumeDeferred) return;
resumeDeferred = false;
const mpvClient = deps.getMpvClient();
if (mpvClient?.connected) {
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
/**
* Holds an overlay request to resume playback (e.g. an auto-pause released because the
* dictionary popup closed) until the pending review ends, then applies it. Returns false
* when no review holds playback, so the caller should resume right away.
*/
function deferPlaybackResume(): boolean {
if (!active && !currentRequest) return false;
resumeDeferred = true;
return true;
}
/**
* The overlay asked to pause while a review holds playback, so closing the review must
* not resume it: drops both a held overlay resume and the review's own restore.
*/
function cancelPlaybackResume(): void {
if (!currentRequest) return;
resumeDeferred = false;
restoreCancelled = true;
if (active) active.restorePlayback = false;
}
function ensureWindow(
review: ActiveReview,
range: RemoteMediaWindowRange,
@@ -413,7 +447,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
setup.values;
const pauseState = booleanProperty(pauseRaw);
pendingPauseRestore = pauseState === false ? mpvClient : null;
// The pause read can predate an overlay pause that arrived during setup.
pendingPauseRestore = pauseState === false && !restoreCancelled ? mpvClient : null;
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
if (lifecycle.isCancelled()) {
restorePendingPlayback();
@@ -531,6 +566,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
const lifecycle = createReviewRequestLifecycle();
currentRequest = lifecycle;
restoreCancelled = false;
try {
return await runReview(request, lifecycle);
} catch {
@@ -541,6 +577,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
} finally {
if (currentRequest === lifecycle) {
currentRequest = null;
resumeDeferredPlayback();
}
lifecycle.markSettled();
}
@@ -739,7 +776,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
if (!current) return;
deps.clearFrameCache?.();
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
if (current.restorePlayback && current.mpvClient.connected) {
if ((current.restorePlayback || resumeDeferred) && current.mpvClient.connected) {
resumeDeferred = false;
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
@@ -760,6 +798,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
getFrame,
stopPreview,
resolveReview,
deferPlaybackResume,
cancelPlaybackResume,
dispose,
};
}
@@ -37,3 +37,10 @@ test('managed background playback handles initial args before deferred overlay w
);
assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(null), false);
});
for (const flag of ['--yomitan', '--hachidori']) {
test(`${flag} settings startup skips heavy startup`, () => {
assert.equal(getStartupModeFlags(parseArgs([flag])).shouldSkipHeavyStartup, true);
assert.equal(getStartupModeFlags(parseArgs([flag, '--start'])).shouldSkipHeavyStartup, false);
});
}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args';
import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
shouldRunYomitanOnlyStartup,
shouldRunDictionarySettingsOnlyStartup,
} from '../../cli/args';
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
@@ -20,7 +20,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
),
shouldSkipHeavyStartup: Boolean(
initialArgs &&
(shouldRunYomitanOnlyStartup(initialArgs) ||
(shouldRunDictionarySettingsOnlyStartup(initialArgs) ||
initialArgs.settings ||
initialArgs.stats ||
initialArgs.dictionary ||
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import * as vm from 'node:vm';
import { createDeps } from '../../core/services/tokenizer/yomitan-scan-test-harness';
import { DEFAULT_CONFIG } from '../../config';
import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service';
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
@@ -30,6 +32,7 @@ function createDeferred<T>() {
function createRuntimeHarness(
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
backgroundState: BackgroundStatsServerState | null = null,
overrides: Partial<StatsServerRuntimeDeps> = {},
) {
const appStateValues: Array<StatsServer | null> = [];
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
@@ -65,10 +68,54 @@ function createRuntimeHarness(
removeBackgroundStatsServerState: () => {},
isBackgroundStatsServerProcessAlive: () => false,
startServer,
...overrides,
});
return { runtime, appStateValues };
}
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
const settings: unknown[] = [];
const context = vm.createContext({
chrome: {},
__subminerSyncAnkiSettings: async (value: unknown) => {
settings.push(value);
return { updated: true, matched: true };
},
__subminerAddNote: async () => ({ noteId: 123, duplicateNoteIds: [] }),
});
vm.runInContext('window = globalThis', context);
const configs: Parameters<NonNullable<StatsServerRuntimeDeps['startServer']>>[0][] = [];
const { runtime } = createRuntimeHarness(
async (config) => {
configs.push(config);
return { close: async () => {} };
},
null,
{
...createDeps(async (script) => structuredClone(await vm.runInContext(script, context))),
getResolvedConfig: () => ({
...DEFAULT_CONFIG,
ankiConnect: {
...DEFAULT_CONFIG.ankiConnect,
enabled: true,
url: 'http://127.0.0.1:8765',
proxy: {
...DEFAULT_CONFIG.ankiConnect.proxy,
enabled: true,
host: '127.0.0.1',
port: 8766,
},
},
}),
},
);
await runtime.ensureStatsServerStarted();
assert.equal(await configs[0]?.addYomitanNote?.('入れる'), 123);
assert.ok(settings[0] && typeof settings[0] === 'object' && 'server' in settings[0]);
assert.equal(settings[0].server, 'http://127.0.0.1:8766');
await runtime.stopStatsServer();
});
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
const calls: string[] = [];
const runtime = createStatsServerRuntime({
+6 -2
View File
@@ -19,7 +19,10 @@ import {
writeBackgroundStatsServerState,
} from './stats-daemon';
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
import {
getPreferredYomitanAnkiServerUrl,
shouldForceOverrideYomitanAnkiServer,
} from './yomitan-anki-server';
export function isSelfOwnedBackgroundStatsDaemonState(state: {
pid: number;
@@ -171,10 +174,11 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
generateSentenceFurigana(text, highlightedText, yomitanDeps, yomitanLogger),
addYomitanNote: async (word: string) => {
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
const ankiUrl = getPreferredYomitanAnkiServerUrl(ankiConnectConfig);
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,
ankiConfig: ankiConnectConfig,
});
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) {
@@ -251,3 +251,99 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues'
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
});
function createTrackingController(loadContent: () => string) {
let currentService: SubtitlePrefetchService | null = null;
const events: string[] = [];
const cueUpdates: Array<string | null> = [];
let serviceCount = 0;
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => loadContent(),
parseSubtitleCues: (content): SubtitleCue[] => [{ startTime: 0, endTime: 1, text: content }],
createSubtitlePrefetchService: () => {
const id = ++serviceCount;
return {
start: () => events.push(`start:${id}`),
stop: () => events.push(`stop:${id}`),
onSeek: () => {},
pause: () => {},
resume: () => {},
};
},
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues, source) => {
cueUpdates.push(cues ? `${source}:${cues[0]!.text}` : null);
},
});
return { controller, events, cueUpdates };
}
test('re-initializing an unchanged source keeps the running service and republishes cues', async () => {
const { controller, events, cueUpdates } = createTrackingController(() => 'content');
await controller.initSubtitlePrefetch('track-1.srt', 0);
await controller.initSubtitlePrefetch('track-1.srt', 3);
assert.deepEqual(events, ['start:1']);
assert.deepEqual(cueUpdates, ['track-1.srt:content', 'track-1.srt:content']);
});
test('re-initializing a source whose content changed restarts the service', async () => {
let content = 'first';
const { controller, events } = createTrackingController(() => content);
await controller.initSubtitlePrefetch('track-1.srt', 0);
content = 'second';
await controller.initSubtitlePrefetch('track-1.srt', 0);
assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']);
});
test('subtitle prefetch init warms overlap lines without publishing them as cues', async () => {
let prefetchedTexts: string[] = [];
let publishedTexts: string[] = [];
let currentService: SubtitlePrefetchService | null = null;
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => currentService,
setCurrentService: (service) => {
currentService = service;
},
loadSubtitleSourceText: async () => 'content',
parseSubtitleCues: (): SubtitleCue[] => [
{ startTime: 0, endTime: 4, text: 'first' },
{ startTime: 2, endTime: 6, text: 'second' },
],
createSubtitlePrefetchService: ({ cues }) => {
prefetchedTexts = cues.map((cue) => cue.text);
return {
start: () => {},
stop: () => {},
onSeek: () => {},
pause: () => {},
resume: () => {},
};
},
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
publishedTexts = cues?.map((cue) => cue.text) ?? [];
},
});
await controller.initSubtitlePrefetch('track.srt', 0);
assert.deepEqual(prefetchedTexts, ['first', 'second', 'first\n\nsecond']);
assert.deepEqual(publishedTexts, ['first', 'second']);
});
+35 -5
View File
@@ -4,6 +4,7 @@ import type {
} from '../../core/services/subtitle-prefetch';
import type { SubtitleData } from '../../types';
import type { SubtitleCue } from '../../types';
import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps';
export interface SubtitlePrefetchInitControllerDeps {
getCurrentService: () => SubtitlePrefetchService | null;
@@ -14,6 +15,7 @@ export interface SubtitlePrefetchInitControllerDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
getCacheGeneration?: () => number;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
@@ -32,11 +34,20 @@ export function createSubtitlePrefetchInitController(
deps: SubtitlePrefetchInitControllerDeps,
): SubtitlePrefetchInitController {
let initRevision = 0;
// What the running service was built from. Startup fires several inits for one source
// (Jellyfin preload seed, sid change, each track-list update); restarting on each would
// discard the in-flight tokenization and re-queue the priority window behind it.
let activeSource: { key: string; content: string; cues: SubtitleCue[] } | null = null;
const stopCurrentService = (): void => {
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
activeSource = null;
};
const cancelPendingInit = (): void => {
initRevision += 1;
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
stopCurrentService();
deps.onParsedSubtitleCuesChanged?.(null, null);
};
@@ -46,14 +57,26 @@ export function createSubtitlePrefetchInitController(
sourceKey = sourcePath,
): Promise<void> => {
const revision = ++initRevision;
deps.getCurrentService()?.stop();
deps.setCurrentService(null);
// The same source may still be current once loaded; keep its service running until then.
if (activeSource?.key !== sourceKey) {
stopCurrentService();
}
try {
const content = await deps.loadSubtitleSourceText(sourcePath);
if (revision !== initRevision) {
return;
}
if (
activeSource?.key === sourceKey &&
activeSource.content === content &&
deps.getCurrentService()
) {
// A track change clears the published cues synchronously, so republish them.
deps.onParsedSubtitleCuesChanged?.(activeSource.cues, sourceKey);
return;
}
stopCurrentService();
const cues = deps.parseSubtitleCues(content, sourcePath);
if (revision !== initRevision || cues.length === 0) {
@@ -66,11 +89,16 @@ export function createSubtitlePrefetchInitController(
return;
}
// Overlap cues only feed the cache; the published cue list stays as authored.
const prefetchCues = [...cues, ...buildOverlapPrefetchCues(cues)].sort(
(a, b) => a.startTime - b.startTime,
);
const nextService = deps.createSubtitlePrefetchService({
cues,
cues: prefetchCues,
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
getCacheGeneration: deps.getCacheGeneration,
});
if (revision !== initRevision) {
@@ -78,6 +106,7 @@ export function createSubtitlePrefetchInitController(
}
deps.setCurrentService(nextService);
activeSource = { key: sourceKey, content, cues };
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
nextService.start(currentTimePos);
deps.logInfo(
@@ -85,6 +114,7 @@ export function createSubtitlePrefetchInitController(
);
} catch (error) {
if (revision === initRevision) {
stopCurrentService();
deps.onParsedSubtitleCuesChanged?.(null, null);
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SubtitleCue } from '../../types';
import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps';
test('overlapping cues yield the combined line the live path resolves', () => {
const cues: SubtitleCue[] = [
{ startTime: 0, endTime: 4, text: 'このまま頑張ったって―' },
{ startTime: 2, endTime: 6, text: 'ちょっとカズマ\n聞こえてんの?' },
];
assert.deepEqual(buildOverlapPrefetchCues(cues), [
{ startTime: 2, endTime: 4, text: 'このまま頑張ったって―\n\nちょっとカズマ\n聞こえてんの?' },
]);
});
test('back-to-back cues yield no overlap lines', () => {
const cues: SubtitleCue[] = [
{ startTime: 0, endTime: 2, text: 'first' },
{ startTime: 2, endTime: 4, text: 'second' },
];
assert.deepEqual(buildOverlapPrefetchCues(cues), []);
});
test('each overlap span joins only the cues active across it, in cue-list order', () => {
const cues: SubtitleCue[] = [
{ startTime: 3, endTime: 8, text: 'C' },
{ startTime: 0, endTime: 6, text: 'A' },
{ startTime: 2, endTime: 4, text: 'B' },
];
assert.deepEqual(buildOverlapPrefetchCues(cues), [
{ startTime: 2, endTime: 3, text: 'A\n\nB' },
{ startTime: 3, endTime: 4, text: 'C\n\nA\n\nB' },
{ startTime: 4, endTime: 6, text: 'C\n\nA' },
]);
});
@@ -0,0 +1,61 @@
import type { SubtitleCue } from '../../types';
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
/**
* While cues overlap, mpv publishes their texts joined as one `sub-text`, and the live
* path tokenizes and caches that combined line as its own entry. Prefetching single cues
* never warms it, so overlapping lines (usually the long ones) always miss the cache.
*
* Returns one synthetic cue per overlap span carrying the text the live path resolves
* for it, so the prefetcher can warm those lines in timeline order with the rest.
*/
export function buildOverlapPrefetchCues(cues: readonly SubtitleCue[]): SubtitleCue[] {
const boundaries = [...new Set(cues.flatMap((cue) => [cue.startTime, cue.endTime]))].sort(
(a, b) => a - b,
);
const indices = cues.map((_, index) => index);
const byStart = [...indices].sort((a, b) => cues[a]!.startTime - cues[b]!.startTime);
const byEnd = [...indices].sort((a, b) => cues[a]!.endTime - cues[b]!.endTime);
const singleTexts = new Set(cues.map((cue) => cue.text));
const seen = new Set<string>();
const overlapCues: SubtitleCue[] = [];
// Sweep the boundaries once, keeping the cues active over [startTime, endTime). Every
// cue edge is a boundary, so a cue is active for a whole interval or not at all.
const active = new Set<number>();
let nextStart = 0;
let nextEnd = 0;
for (let i = 0; i + 1 < boundaries.length; i += 1) {
const startTime = boundaries[i]!;
const endTime = boundaries[i + 1]!;
for (; nextEnd < byEnd.length && cues[byEnd[nextEnd]!]!.endTime <= startTime; nextEnd += 1) {
active.delete(byEnd[nextEnd]!);
}
for (
;
nextStart < byStart.length && cues[byStart[nextStart]!]!.startTime <= startTime;
nextStart += 1
) {
const index = byStart[nextStart]!;
// Empty or inverted cues are never on screen.
if (cues[index]!.endTime > startTime) active.add(index);
}
if (active.size < 2) continue;
// mpv joins simultaneous cues in cue-list order.
const liveText = [...active]
.sort((a, b) => a - b)
.map((index) => cues[index]!.text)
.join('\n');
const text = resolvePrimarySubtitleText({
liveText,
currentTimeSec: (startTime + endTime) / 2,
cues,
});
if (!text.trim() || singleTexts.has(text) || seen.has(text)) continue;
seen.add(text);
overlapCues.push({ startTime, endTime, text });
}
return overlapCues;
}
@@ -26,6 +26,8 @@ function buildHandlers(overrides: Partial<TrayTemplateDeps> = {}) {
showFirstRunSetup: () => true,
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => false,
getDictionaryBackend: () => 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
+8
View File
@@ -1,3 +1,5 @@
import type { DictionaryBackend } from '../../types/config';
export function createResolveTrayIconPathHandler(deps: {
resolveTrayIconPathRuntime: (options: {
platform: string;
@@ -46,6 +48,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
dictionaryBackend: DictionaryBackend;
openHachidoriSettings: () => void;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
@@ -68,6 +72,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: (force?: boolean) => void;
showWindowsMpvLauncherSetup: () => boolean;
getDictionaryBackend: () => DictionaryBackend;
openHachidoriSettings: () => void;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
@@ -109,6 +115,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
deps.openFirstRunSetupWindow(true);
},
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
dictionaryBackend: deps.getDictionaryBackend(),
openHachidoriSettings: deps.openHachidoriSettings,
openYomitanSettings: () => {
deps.openYomitanSettings();
},
+8
View File
@@ -1,3 +1,5 @@
import type { DictionaryBackend } from '../../types/config';
export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
resolveTrayIconPathRuntime: (options: {
platform: string;
@@ -36,6 +38,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
dictionaryBackend: DictionaryBackend;
openHachidoriSettings: () => void;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
@@ -58,6 +62,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: (force?: boolean) => void;
showWindowsMpvLauncherSetup: () => boolean;
getDictionaryBackend: () => DictionaryBackend;
openHachidoriSettings: () => void;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
@@ -84,6 +90,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
getDictionaryBackend: deps.getDictionaryBackend,
openHachidoriSettings: deps.openHachidoriSettings,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
+23
View File
@@ -38,6 +38,8 @@ function baseHandlers(overrides: Partial<TrayMenuHandlers> = {}): TrayMenuHandle
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
dictionaryBackend: 'yomitan',
openHachidoriSettings: () => undefined,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
@@ -74,6 +76,27 @@ for (const entry of optionalEntries) {
});
}
for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
test(`tray menu shows only ${dictionaryBackend} settings and dispatches its handler`, () => {
const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime(
baseHandlers({
dictionaryBackend,
openHachidoriSettings: () => calls.push('hachidori'),
openYomitanSettings: () => calls.push('yomitan'),
}),
);
const labels = template.flatMap((entry) => (entry.label ? [entry.label] : []));
const [shown, hidden] =
dictionaryBackend === 'hachidori'
? ['Open Hachidori Settings', 'Open Yomitan Settings']
: ['Open Yomitan Settings', 'Open Hachidori Settings'];
assert.equal(labels.includes(hidden), false);
template.find((entry) => entry.label === shown)?.click?.();
assert.deepEqual(calls, [dictionaryBackend]);
});
}
test('tray menu ends with a separator and Quit', () => {
const template = buildTrayMenuTemplateRuntime(baseHandlers());
assert.deepEqual(
+12 -2
View File
@@ -1,3 +1,5 @@
import type { DictionaryBackend } from '../../types/config';
export function resolveTrayIconPathRuntime(deps: {
platform: string;
resourcesPath: string;
@@ -39,6 +41,8 @@ export type TrayMenuActionHandlers = {
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
dictionaryBackend: DictionaryBackend;
openHachidoriSettings: () => void;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
@@ -103,8 +107,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
]
: []),
{
label: 'Open Yomitan Settings',
click: handlers.openYomitanSettings,
label:
handlers.dictionaryBackend === 'hachidori'
? 'Open Hachidori Settings'
: 'Open Yomitan Settings',
click:
handlers.dictionaryBackend === 'hachidori'
? handlers.openHachidoriSettings
: handlers.openYomitanSettings,
},
{
label: 'Open SubMiner Settings',
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AnkiConnectConfig, ResolvedConfig } from '../../types';
import { createYomitanAnkiServerSyncRuntime } from './yomitan-anki-server-sync';
import {
buildYomitanAnkiSettingsKey,
createYomitanAnkiServerSyncRuntime,
} from './yomitan-anki-server-sync';
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
type SyncCall = { url: string; forceOverride: boolean; deck: string };
@@ -64,3 +68,15 @@ test('retries the same settings after a failed sync', async () => {
await harness.sync();
assert.equal(harness.calls.length, 2);
});
test('settings sync key changes when fields or tags change', () => {
const key = (word: string, tags: string[]) =>
buildYomitanAnkiSettingsKey({
targetUrl: 'http://127.0.0.1:8766',
targetDeck: 'Mining',
forceOverride: true,
hachidoriHints: buildHachidoriAnkiHints({ fields: { word }, tags }),
});
assert.notEqual(key('Word', ['SubMiner']), key('Expression', ['SubMiner']));
assert.notEqual(key('Word', ['SubMiner']), key('Word', ['Japanese']));
});
+5 -1
View File
@@ -1,5 +1,6 @@
import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services';
import type { ResolvedConfig } from '../../types';
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
shouldForceOverrideYomitanAnkiServer,
@@ -18,8 +19,9 @@ export function buildYomitanAnkiSettingsKey(options: {
targetUrl: string;
targetDeck: string;
forceOverride: boolean;
hachidoriHints?: ReturnType<typeof buildHachidoriAnkiHints>;
}): string {
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`;
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}\n${JSON.stringify(options.hachidoriHints)}`;
}
export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): {
@@ -47,6 +49,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
targetUrl,
targetDeck,
forceOverride,
hachidoriHints: buildHachidoriAnkiHints(ankiConnectConfig),
});
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
return;
@@ -66,6 +69,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
{
forceOverride,
deck: targetDeck,
ankiConfig: ankiConnectConfig,
},
);