feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
@@ -72,6 +72,7 @@ test('build cli command context deps maps handlers and values', () => {
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
openSyncUiWindow: () => {},
@@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () =
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
@@ -103,6 +103,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
openSyncUiWindow: () => {},
@@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -144,6 +145,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(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
@@ -56,6 +56,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;
cycleSecondarySubMode: () => void;
@@ -134,6 +135,7 @@ export function createCliCommandContext(
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -50,6 +50,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
@@ -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,
setup: false,
@@ -762,3 +768,115 @@ 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('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,
};
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');
host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Host offline' };
snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.canFinish, false);
assert.equal(snapshot.dictionaryCount, 0);
assert.equal(snapshot.state.status, 'incomplete');
assert.notEqual((await service.markSetupCompleted()).state.status, 'completed');
host = {
kind: 'connected',
address: 'ws://127.0.0.1:8771/link',
name: 'Docker',
dictionaryCount: 0,
};
assert.equal((await service.getSetupStatus()).canFinish, false);
host = { kind: 'local' };
assert.equal((await service.getSetupStatus()).dictionaryCount, 7);
assert.equal((await service.getSetupStatus()).canFinish, true);
});
});
+98 -5
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,44 @@ 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();
if (getSetupStateDictionaryBackend(stored) === backend) return stored;
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] : []),
]),
];
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] : others,
};
writeSetupState(setupStatePath, state);
completed = state.status === 'completed';
deps.onStateChanged?.(state);
@@ -292,10 +373,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 +397,8 @@ export function createFirstRunSetupService(deps: {
installedWindowsMpvShortcuts,
);
return {
dictionaryBackend: getDictionaryBackend(),
hachidoriHost,
configReady,
dictionaryCount,
canFinish: isYomitanSetupSatisfied({
@@ -353,11 +438,19 @@ export function createFirstRunSetupService(deps: {
return {
ensureSetupStateInitialized: async () => {
const state = readState();
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 } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
getDictionaryBackend,
getHachidoriHostStatus: deps.getHachidoriHostStatus,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const canFinish = isYomitanSetupSatisfied({
@@ -779,3 +779,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;
@@ -249,9 +257,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
@@ -268,8 +314,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>
@@ -338,6 +384,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;
@@ -466,17 +527,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>
@@ -495,6 +557,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' &&
@@ -507,6 +571,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,
@@ -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
View File
@@ -172,6 +172,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
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) {
@@ -71,6 +71,8 @@ test('build tray template handler wires actions and init guards', () => {
showFirstRunSetup: () => true,
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
getDictionaryBackend: () => 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
@@ -127,6 +129,8 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
showFirstRunSetup: () => false,
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
getDictionaryBackend: () => 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('configuration'),
+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;
@@ -67,6 +71,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: (force?: boolean) => void;
showWindowsMpvLauncherSetup: () => boolean;
getDictionaryBackend: () => DictionaryBackend;
openHachidoriSettings: () => void;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
@@ -107,6 +113,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
deps.openFirstRunSetupWindow(true);
},
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
dictionaryBackend: deps.getDictionaryBackend(),
openHachidoriSettings: deps.openHachidoriSettings,
openYomitanSettings: () => {
deps.openYomitanSettings();
},
+4
View File
@@ -31,6 +31,8 @@ test('tray main deps builders return mapped handlers', () => {
showFirstRunSetup: () => true,
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
getDictionaryBackend: () => 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
@@ -58,6 +60,8 @@ test('tray main deps builders return mapped handlers', () => {
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
showWindowsMpvLauncherSetup: true,
dictionaryBackend: 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettings: () => calls.push('open-configuration'),
openSyncUi: () => {},
+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;
@@ -57,6 +61,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: (force?: boolean) => void;
showWindowsMpvLauncherSetup: () => boolean;
getDictionaryBackend: () => DictionaryBackend;
openHachidoriSettings: () => void;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
@@ -82,6 +88,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,
@@ -31,6 +31,8 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true,
getDictionaryBackend: () => 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
+97 -78
View File
@@ -26,85 +26,96 @@ test('resolve tray icon returns null when no asset exists', () => {
assert.equal(path, null);
});
test('tray menu template contains expected entries and handlers', () => {
const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => calls.push('help'),
openChangelog: () => calls.push('changelog'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: true,
openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettings: () => calls.push('configuration'),
openSyncUi: () => calls.push('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
openAnilistSetup: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
const settingsLabel =
dictionaryBackend === 'hachidori' ? 'Open Hachidori Settings' : 'Open Yomitan Settings';
test(`tray menu shows only ${dictionaryBackend} settings and dispatches its handler`, () => {
const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => calls.push('help'),
openChangelog: () => calls.push('changelog'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: true,
openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
showWindowsMpvLauncherSetup: true,
dictionaryBackend,
openHachidoriSettings: () => calls.push('hachidori'),
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettings: () => calls.push('configuration'),
openSyncUi: () => calls.push('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
openAnilistSetup: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
});
// Resolve by label, not index: adding a menu entry should not force every
// later assertion in this test to be renumbered.
const entryFor = (label: string) => {
const entry = template.find((candidate) => candidate.label === label);
assert.ok(entry, `expected a "${label}" tray entry`);
return entry;
};
assert.deepEqual(
template.map((entry) => entry.label ?? `<${entry.type}>`),
[
'Open Help',
'View Changelog',
'Open Texthooker',
'Complete Setup',
'Open SubMiner Setup',
settingsLabel,
'Open SubMiner Settings',
'Sync Stats && History',
'Export Logs',
'Configure Jellyfin',
'Jellyfin Discovery',
'Configure AniList',
'Check for Updates',
'<separator>',
'Quit',
],
);
entryFor(settingsLabel).click?.();
assert.equal(calls.length, 1);
assert.equal(calls[0], dictionaryBackend);
calls.length = 0;
const discovery = entryFor('Jellyfin Discovery');
assert.equal(discovery.type, 'checkbox');
assert.equal(discovery.checked, false);
discovery.click?.({ checked: true });
entryFor('Open Help').click?.();
entryFor('View Changelog').click?.();
entryFor('Open Texthooker').click?.();
entryFor('Sync Stats && History').click?.();
entryFor('Export Logs').click?.();
entryFor('Check for Updates').click?.();
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
entryFor('Quit').click?.();
assert.deepEqual(calls, [
'jellyfin-discovery:true',
'help',
'changelog',
'texthooker',
'sync-ui',
'export-logs',
'updates',
'separator',
'quit',
]);
});
// Resolve by label, not index: adding a menu entry should not force every
// later assertion in this test to be renumbered.
const entryFor = (label: string) => {
const entry = template.find((candidate) => candidate.label === label);
assert.ok(entry, `expected a "${label}" tray entry`);
return entry;
};
assert.deepEqual(
template.map((entry) => entry.label ?? `<${entry.type}>`),
[
'Open Help',
'View Changelog',
'Open Texthooker',
'Complete Setup',
'Open SubMiner Setup',
'Open Yomitan Settings',
'Open SubMiner Settings',
'Sync Stats && History',
'Export Logs',
'Configure Jellyfin',
'Jellyfin Discovery',
'Configure AniList',
'Check for Updates',
'<separator>',
'Quit',
],
);
const discovery = entryFor('Jellyfin Discovery');
assert.equal(discovery.type, 'checkbox');
assert.equal(discovery.checked, false);
discovery.click?.({ checked: true });
entryFor('Open Help').click?.();
entryFor('View Changelog').click?.();
entryFor('Open Texthooker').click?.();
entryFor('Sync Stats && History').click?.();
entryFor('Export Logs').click?.();
entryFor('Check for Updates').click?.();
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
entryFor('Quit').click?.();
assert.deepEqual(calls, [
'jellyfin-discovery:true',
'help',
'changelog',
'texthooker',
'sync-ui',
'export-logs',
'updates',
'separator',
'quit',
]);
});
}
test('tray menu template omits first-run setup entry when setup is complete', () => {
const labels = buildTrayMenuTemplateRuntime({
@@ -116,6 +127,8 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
dictionaryBackend: 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
@@ -146,6 +159,8 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
dictionaryBackend: 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
@@ -174,6 +189,8 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
dictionaryBackend: 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
@@ -203,6 +220,8 @@ test('tray menu template renders a visible linux discovery check mark when activ
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
dictionaryBackend: 'yomitan',
openHachidoriSettings: () => {},
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
+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;
@@ -102,8 +106,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,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync';
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
test('buildYomitanAnkiSettingsKey includes force override policy', () => {
assert.notEqual(
@@ -16,3 +17,15 @@ test('buildYomitanAnkiSettingsKey includes force override policy', () => {
}),
);
});
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,
@@ -17,8 +18,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): {
@@ -45,6 +47,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
targetUrl,
targetDeck,
forceOverride,
hachidoriHints: buildHachidoriAnkiHints(ankiConnectConfig),
});
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
return;
@@ -64,6 +67,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
{
forceOverride,
deck: targetDeck,
ankiConfig: ankiConnectConfig,
},
);