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
+3
View File
@@ -1793,6 +1793,9 @@ export class AnkiIntegration {
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
): Promise<MediaTimingReviewDecision> {
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
log.debug(
`[media-timing] review skipped: reviewTiming=${String(this.config.media?.reviewTiming)} callback=${this.mediaTimingReviewCallback ? 'set' : 'missing'}`,
);
return { action: 'use-original' };
}
return await this.mediaTimingReviewCallback({
@@ -441,6 +441,70 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
}
});
test('proxy enriches confirmed Hachidori overwrites without counting a new card or forwarding metadata', async () => {
const received: unknown[] = [];
let upstreamError: string | null = null;
const upstream = http.createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
received.push(JSON.parse(Buffer.concat(chunks).toString()));
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ result: null, error: upstreamError }));
});
upstream.listen(0, '127.0.0.1');
await once(upstream, 'listening');
const address = upstream.address();
assert.ok(address && typeof address === 'object');
const processed: number[] = [];
const added: number[] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (id) => {
processed.push(id);
},
recordCardsAdded: (count) => {
added.push(count);
},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
});
try {
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
await proxy.waitUntilReady();
const server: unknown = Reflect.get(proxy, 'server');
assert.ok(server instanceof http.Server);
const bound = server.address();
assert.ok(bound && typeof bound === 'object');
for (const [id, marked, error] of [
[51, true, null],
[52, false, null],
[53, true, 'failed'],
] satisfies Array<[number, boolean, string | null]>) {
upstreamError = error;
await fetch(`http://127.0.0.1:${bound.port}`, {
method: 'POST',
body: JSON.stringify({
action: 'updateNoteFields',
version: 6,
params: {
note: { id, fields: { Expression: '猫' } },
...(marked ? { subminerEnrich: true } : {}),
},
}),
});
}
await waitForCondition(() => processed.length > 0);
assert.deepEqual(processed, [51]);
assert.deepEqual(added, []);
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
} finally {
proxy.stop();
upstream.close();
await once(upstream, 'close');
}
});
test('proxy returns addNote response without waiting for background enrichment', async () => {
const processed: number[] = [];
let releaseProcessing: (() => void) | undefined;
+34 -4
View File
@@ -224,6 +224,31 @@ export class AnkiConnectProxyServer {
typeof requestJson.action === 'string'
? requestJson.action
: String(requestJson.action ?? '');
if (action === 'updateNoteFields') {
const params = requestJson.params;
if (
!params ||
typeof params !== 'object' ||
!('subminerEnrich' in params) ||
params.subminerEnrich !== true
)
return;
const note = 'note' in params ? params.note : null;
if (!note || typeof note !== 'object' || !('id' in note)) return;
const response = this.tryParseJsonValue(responseBody);
// AnkiConnect confirms updates with {result:null,error:null}; failures must never enrich.
if (
!response ||
typeof response !== 'object' ||
!('error' in response) ||
response.error !== null ||
!('result' in response) ||
response.result !== null
)
return;
this.enqueueNotes(this.collectSingleResultId(note.id), false);
return;
}
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
return;
}
@@ -293,7 +318,7 @@ export class AnkiConnectProxyServer {
typeof requestJson.action === 'string'
? requestJson.action
: String(requestJson.action ?? '');
if (action !== 'addNote') {
if (action !== 'addNote' && action !== 'updateNoteFields') {
return requestJson;
}
@@ -301,12 +326,17 @@ export class AnkiConnectProxyServer {
requestJson.params && typeof requestJson.params === 'object'
? (requestJson.params as Record<string, unknown>)
: null;
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
if (
!params ||
(!Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds') &&
!Object.prototype.hasOwnProperty.call(params, 'subminerEnrich'))
) {
return requestJson;
}
const nextParams = { ...params };
delete nextParams.subminerDuplicateNoteIds;
delete nextParams.subminerEnrich;
return {
...requestJson,
params: nextParams,
@@ -455,7 +485,7 @@ export class AnkiConnectProxyServer {
});
}
private enqueueNotes(noteIds: number[]): void {
private enqueueNotes(noteIds: number[], recordAdded = true): void {
let enqueuedCount = 0;
const acceptedIds: number[] = [];
for (const noteId of noteIds) {
@@ -472,7 +502,7 @@ export class AnkiConnectProxyServer {
return;
}
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
if (recordAdded) this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
this.processQueue();
}
+19
View File
@@ -8,6 +8,7 @@ import {
isStandaloneTexthookerCommand,
parseArgs,
shouldRunYomitanOnlyStartup,
shouldRunDictionarySettingsOnlyStartup,
shouldStartApp,
} from './args';
@@ -475,3 +476,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(setup), true);
assert.equal(shouldStartApp(setup), true);
});
test('Hachidori settings starts the app without overlay or mpv prerequisites', () => {
const args = parseArgs(['--hachidori']);
assert.equal(args.hachidori, true);
assert.equal(args.yomitan, false);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(shouldRunDictionarySettingsOnlyStartup(args), true);
assert.equal(shouldRunYomitanOnlyStartup(args), false);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(commandNeedsOverlayStartupPrereqs(args), false);
assert.equal(isStandaloneTexthookerCommand(parseArgs(['--texthooker', '--hachidori'])), false);
assert.equal(
shouldRunDictionarySettingsOnlyStartup(parseArgs(['--hachidori', '--start'])),
false,
);
assert.equal(shouldRunDictionarySettingsOnlyStartup(parseArgs(['--yomitan'])), true);
});
+11 -1
View File
@@ -13,6 +13,7 @@ export interface CliArgs {
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
yomitan: boolean;
hachidori: boolean;
settings: boolean;
syncWindow: boolean;
setup: boolean;
@@ -134,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
@@ -285,6 +287,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--hachidori') args.hachidori = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--sync-window') args.syncWindow = true;
else if (arg === '--setup') args.setup = true;
@@ -568,6 +571,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.hachidori ||
args.settings ||
args.syncWindow ||
args.setup ||
@@ -647,6 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.yomitan &&
!args.hachidori &&
!args.settings &&
!args.syncWindow &&
!args.setup &&
@@ -719,6 +724,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.hachidori ||
args.settings ||
args.syncWindow ||
args.setup ||
@@ -769,8 +775,12 @@ export function shouldStartApp(args: CliArgs): boolean {
}
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
return args.yomitan && !args.hachidori && shouldRunDictionarySettingsOnlyStartup(args);
}
export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
return (
args.yomitan &&
(args.yomitan || args.hachidori) &&
!args.background &&
!args.start &&
!args.stop &&
+1
View File
@@ -24,6 +24,7 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--settings\s+Open SubMiner settings window/);
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
assert.match(output, /--hachidori\s+Open Hachidori settings window/);
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
+1
View File
@@ -25,6 +25,7 @@ ${B}Overlay${R}
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--yomitan Open Yomitan settings window
--hachidori Open Hachidori settings window
--settings Open SubMiner settings window
--setup Open first-run setup window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
+2
View File
@@ -22,6 +22,7 @@ export type {
} from './definitions/shared';
const {
dictionaryBackend,
subtitlePosition,
keybindings,
websocket,
@@ -57,6 +58,7 @@ const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
dictionaryBackend,
subtitlePosition,
keybindings,
websocket,
+2
View File
@@ -2,6 +2,7 @@ import { ResolvedConfig } from '../../types/config';
export const CORE_DEFAULT_CONFIG: Pick<
ResolvedConfig,
| 'dictionaryBackend'
| 'subtitlePosition'
| 'keybindings'
| 'websocket'
@@ -18,6 +19,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'notifications'
| 'auto_start_overlay'
> = {
dictionaryBackend: 'yomitan',
subtitlePosition: { yPercent: 10 },
keybindings: [],
websocket: {
+7
View File
@@ -81,6 +81,13 @@ export function buildCoreConfigOptionRegistry(
] as const;
return [
{
path: 'dictionaryBackend',
kind: 'enum',
enumValues: ['yomitan', 'hachidori'],
defaultValue: defaultConfig.dictionaryBackend,
description: 'Dictionary lookup backend. Restart SubMiner after changing this setting.',
},
{
path: 'logging.level',
kind: 'enum',
@@ -1,6 +1,14 @@
import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Dictionary Backend',
description: ['Select the dictionary lookup backend: yomitan or hachidori.'],
notes: [
'Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.',
],
key: 'dictionaryBackend',
},
{
title: 'Japanese Subtitle Generation',
description: [
+11
View File
@@ -6,6 +6,17 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (src.dictionaryBackend === 'yomitan' || src.dictionaryBackend === 'hachidori') {
resolved.dictionaryBackend = src.dictionaryBackend;
} else if (src.dictionaryBackend !== undefined) {
warn(
'dictionaryBackend',
src.dictionaryBackend,
resolved.dictionaryBackend,
"Expected 'yomitan' or 'hachidori'.",
);
}
if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
if (launchAtStartup !== undefined) {
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
import { buildConfigSettingsRegistry } from '../settings/registry';
import { createResolveContext } from './context';
import { applyCoreDomainConfig } from './core-domains';
test('dictionary backend defaults to Yomitan and accepts Hachidori', () => {
assert.equal(resolveConfig({}).resolved.dictionaryBackend, 'yomitan');
const { resolved, warnings } = resolveConfig({ dictionaryBackend: 'hachidori' });
assert.equal(resolved.dictionaryBackend, 'hachidori');
assert.deepEqual(warnings, []);
const field = buildConfigSettingsRegistry(resolved).find(
(entry) => entry.configPath === 'dictionaryBackend',
);
assert.equal(field?.restartBehavior, 'restart');
assert.deepEqual(field?.enumValues, ['yomitan', 'hachidori']);
assert.equal(field?.category, 'integrations');
});
test('unknown dictionary backend values warn and preserve the default', () => {
for (const dictionaryBackend of ['unknown', '', null, true, {}]) {
const { context, warnings } = createResolveContext({});
context.src.dictionaryBackend = dictionaryBackend;
applyCoreDomainConfig(context);
assert.equal(context.resolved.dictionaryBackend, 'yomitan');
assert.equal(warnings.length, 1);
assert.equal(warnings[0]?.path, 'dictionaryBackend');
}
});
+7 -3
View File
@@ -152,6 +152,7 @@ const SECTION_ORDER = new Map<string, number>(
'Discord Rich Presence',
'Jellyfin',
'Texthooker',
'Dictionary Lookup',
'Yomitan',
'Stats dashboard',
'Startup warmups',
@@ -234,8 +235,8 @@ const LABEL_OVERRIDES: Record<string, string> = {
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
'subtitleStyle.css': 'CSS Declarations',
@@ -276,7 +277,7 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'subtitleStyle.primaryVisibleOnYomitanPopup':
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.',
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a dictionary popup is open.',
'websocket.enabled':
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs':
@@ -340,6 +341,9 @@ function humanizePath(path: string): string {
}
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
if (path === 'dictionaryBackend') {
return { category: 'integrations', section: 'Dictionary Lookup' };
}
if (
path === 'subtitleStyle.autoPauseVideoOnHover' ||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
+1
View File
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
+16
View File
@@ -20,6 +20,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
@@ -133,6 +134,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
togglePrimarySubtitleBar: () => {
calls.push('togglePrimarySubtitleBar');
},
openHachidoriSettingsDelayed: (delayMs) => {
calls.push(`openHachidoriSettingsDelayed:${delayMs}`);
},
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
@@ -673,6 +677,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
},
ui: {
openFirstRunSetup: () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
@@ -1133,3 +1138,14 @@ test('handleCliCommand reports async refresh-known-words errors to OSD', async (
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
assert.ok(calls.includes('stopApp'));
});
for (const source of ['initial', 'second-instance'] as const) {
test(`Hachidori settings command opens its own settings on ${source} invocation`, () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ hachidori: true }), source, deps);
assert.ok(calls.includes('openHachidoriSettingsDelayed:1000'));
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
assert.equal(calls.includes('initializeOverlayRuntime'), false);
assert.equal(calls.includes('connectMpvClient'), false);
});
}
+9
View File
@@ -43,6 +43,7 @@ export interface CliCommandServiceDeps {
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
openHachidoriSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
@@ -170,6 +171,7 @@ interface MiningCliRuntime {
interface UiCliRuntime {
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -275,6 +277,11 @@ export function createCliCommandDepsRuntime(
options.ui.openYomitanSettings();
}, delayMs);
},
openHachidoriSettingsDelayed: (delayMs) => {
options.schedule(() => {
options.ui.openHachidoriSettings();
}, delayMs);
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
@@ -425,6 +432,8 @@ export function handleCliCommand(
deps.logDebug('Opened first-run setup flow.');
} else if (args.yomitan) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.hachidori) {
deps.openHachidoriSettingsDelayed(1000);
} else if (args.settings) {
deps.openConfigSettingsWindow();
} else if (args.syncWindow) {
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveHachidoriExtensionPath } from './hachidori-extension';
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
test('Hachidori resolves development and packaged artifacts without falling back to Yomitan', () => {
const options = { moduleDir: '/app/dist/core/services', resourcesPath: '/resources' };
assert.equal(
resolveHachidoriExtensionPath({
...options,
exists: (p) => p === '/app/build/hachidori/manifest.json',
}),
'/app/build/hachidori',
);
assert.equal(
resolveHachidoriExtensionPath({
...options,
exists: (p) => p === '/resources/hachidori/manifest.json',
}),
'/resources/hachidori',
);
assert.throws(
() => resolveHachidoriExtensionPath({ ...options, exists: () => false }),
/build:hachidori/,
);
});
test('Hachidori updates its own extension copy and preserves the Yomitan copy', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-backend-copy-'));
try {
const source = path.join(root, 'source');
const profile = path.join(root, 'profile');
fs.mkdirSync(source);
fs.writeFileSync(path.join(source, 'manifest.json'), '{"name":"Hachidori","version":"1"}');
fs.mkdirSync(path.join(profile, 'extensions/yomitan'), { recursive: true });
fs.writeFileSync(path.join(profile, 'extensions/yomitan/marker'), 'preserved');
const options = { extensionName: 'hachidori', platform: 'linux' } satisfies Parameters<
typeof ensureExtensionCopyAsync
>[2];
const copy = await ensureExtensionCopyAsync(source, profile, options);
assert.equal(copy.targetDir, path.join(profile, 'extensions/hachidori'));
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, false);
fs.writeFileSync(path.join(source, 'bridge.js'), 'updated');
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, true);
assert.equal(
fs.readFileSync(path.join(profile, 'extensions/yomitan/marker'), 'utf8'),
'preserved',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+59
View File
@@ -0,0 +1,59 @@
import electron from 'electron';
import type { Extension, Session } from 'electron';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
import { HACHIDORI_SESSION_PARTITION } from './tokenizer/hachidori-parser-bridge';
export function getHachidoriSession(): Session {
return electron.session.fromPartition(HACHIDORI_SESSION_PARTITION);
}
export function resolveHachidoriExtensionPath(options: {
moduleDir: string;
resourcesPath: string;
exists?: (candidate: string) => boolean;
}): string {
const candidates = [
path.resolve(options.moduleDir, '../../../build/hachidori'),
path.join(options.resourcesPath, 'hachidori'),
];
const found = candidates.find((candidate) =>
(options.exists ?? existsSync)(path.join(candidate, 'manifest.json')),
);
if (!found) throw new Error('Hachidori is not bundled. Run bun run build:hachidori.');
return found;
}
/** Separate session keeps settings-only launches from injecting into the other backend's overlay. */
export function createHachidoriExtensionRuntime(userDataPath: string) {
let extension: Extension | null = null;
let loading: Promise<Extension> | null = null;
return {
getSession: getHachidoriSession,
ensureLoaded(): Promise<Extension> {
if (extension) return Promise.resolve(extension);
if (loading) return loading;
loading = (async () => {
const source = resolveHachidoriExtensionPath({
moduleDir: __dirname,
resourcesPath: process.resourcesPath,
});
const copy = await ensureExtensionCopyAsync(source, userDataPath, {
extensionName: 'hachidori',
});
const session = getHachidoriSession();
// Electron can reuse an old extension worker after its files change.
// Drop worker registrations before loading, preserving dictionaries and settings.
await session.clearStorageData({ storages: ['serviceworkers'] });
extension = await session.extensions.loadExtension(copy.targetDir, {
allowFileAccess: true,
});
return extension;
})().finally(() => {
loading = null;
});
return loading;
},
};
}
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
import vm from 'node:vm';
import { buildHachidoriAnkiHints, HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
const config = {
tags: ['SubMiner', 'Japanese'],
fields: { word: 'Term', sentence: 'Context', wordAudio: 'Pronunciation', image: 'Image' },
};
const modelFields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
async function harness(
anki: unknown = {},
models: Record<string, string[]> = { Japanese: modelFields },
) {
const templates: unknown = await import(
pathToFileURL(path.join(extensionPath, 'anki-templates.js')).href
);
const setup: unknown = await import(
pathToFileURL(path.join(extensionPath, 'anki-setup.js')).href
);
const context = vm.createContext({
__templates: templates,
__setup: setup,
__initialAnki: anki,
__models: models,
structuredClone,
URL,
});
vm.runInContext('globalThis.window = globalThis', context);
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
vm.runInContext(
`
let options = { ...HDReaderOptions.normaliseOptions({ anki: __initialAnki }), revision: 1 };
let writes = 0, online = true, race = false, proxy = null;
const readOptions = async () => structuredClone(options);
globalThis.__subminerSetAnkiProxyUrl = async value => { const old = proxy; proxy = value; return old; };
const send = async (type, request, target) => {
if (type !== 'hd_options_write' || target !== 'hoshidicts-worker') throw Error('Unexpected request');
if (race) {
race = false;
options.anki.templates[0].deck = options.anki.deck = 'User edit';
options.revision++;
}
if (request.baseRevision !== options.revision) throw Error('conflict');
options = { ...HDReaderOptions.normaliseOptions({ ...options, ...request.options }), revision: options.revision + 1 };
writes++;
};
const __gateway = { createAnkiGateway: () => ({ discover: async ({ model }) => ({
connected: online, model, models: Object.keys(__models), decks: ['Mining'],
fields: __models[model] || [], errors: online ? [] : ['offline'],
}) }) };
`,
context,
);
await vm.runInContext(
HACHIDORI_ANKI_SETTINGS_SCRIPT.replace("await import('./anki-templates.js')", '__templates')
.replace("await import('./anki-setup.js')", '__setup')
.replace("await import('./anki.js')", '__gateway'),
context,
);
const run = async (script: string): Promise<unknown> =>
structuredClone(await vm.runInContext(script, context));
const sync = () =>
run(
`__subminerSyncAnkiSettings(${JSON.stringify({
server: 'http://127.0.0.1:8766',
deck: 'Mining',
forceOverride: true,
hints: buildHachidoriAnkiHints(config),
})})`,
);
return { run, sync };
}
test('fresh Hachidori settings inherit deck, tags, a unique model and configured fields', async () => {
const h = await harness();
assert.deepEqual(await h.sync(), { updated: true, matched: true, pending: false });
assert.deepEqual(
await h.run('[options.anki.url, options.anki.deck, options.anki.model, options.anki.tags]'),
['http://127.0.0.1:8766', 'Mining', 'Japanese', config.tags],
);
assert.deepEqual(
await h.run(
'Object.fromEntries(Object.entries(options.anki.fieldTemplates).map(([key, row]) => [key, row.value]))',
),
{
Term: '{expression}',
Reading: '{reading}',
Definition: '{definition}',
Context: '{sentence}',
Pronunciation: '{audio}',
Image: '{screenshot}',
},
);
await h.sync();
assert.equal(await h.run('writes'), 1);
});
test('preserves custom templates, tags, intentional blank fields and additional templates', async () => {
const h = await harness({
templates: [
{
id: 'default',
name: 'Custom',
deck: 'Own deck',
model: 'Japanese',
tags: ['own'],
fieldTemplates: {
Term: { value: '{reading}', overwriteMode: 'overwrite' },
Context: { value: '', overwriteMode: 'coalesce' },
},
},
{ id: 'second', name: 'Second', deck: 'Other', model: 'Other', tags: [] },
],
});
const before = await h.run('options.anki.templates');
await h.sync();
assert.deepEqual(await h.run('options.anki.templates'), before);
});
test('leaves an ambiguous model unset and retries discovery after Anki reconnects', async () => {
const h = await harness({}, { Japanese: modelFields, Second: modelFields });
await h.run('online = false');
assert.deepEqual(await h.sync(), { updated: true, matched: false, pending: true });
assert.equal(await h.run('options.anki.deck'), 'Mining');
await h.run('online = true');
await h.sync();
assert.equal(await h.run('options.anki.model'), '');
await h.run('delete __models.Second');
await h.sync();
assert.equal(await h.run('options.anki.model'), 'Japanese');
});
test('fills missing basic mappings only with fields belonging to the selected model', async () => {
const h = await harness(
{ model: 'Japanese', fields: { expression: 'Reading' } },
{ Japanese: ['Term', 'Reading', 'Context'] },
);
await h.sync();
assert.deepEqual(
await h.run(
'[options.anki.fields.expression, options.anki.fields.sentence, options.anki.fields.audio, options.anki.fieldTemplates]',
),
['Reading', 'Context', '', null],
);
});
test('re-reads concurrent settings edits before retrying its revisioned write', async () => {
const h = await harness();
await h.run('race = true');
await h.sync();
assert.equal(await h.run('options.anki.deck'), 'User edit');
assert.equal(await h.run('options.anki.model'), 'Japanese');
});
test('keeps sentence audio out of captured-audio settings and uses wordAudio first', () => {
const hints = buildHachidoriAnkiHints({
fields: { audio: 'SentenceAudio', wordAudio: 'WordAudio' },
});
assert.equal(hints.fields.audio, 'WordAudio');
assert.equal('captureAudio' in hints.fields, false);
});
@@ -0,0 +1,120 @@
import type { AnkiConnectConfig } from '../../../types';
// Only settings with the same meaning in both apps cross this boundary.
export function buildHachidoriAnkiHints(config: AnkiConnectConfig) {
return {
tags: config.tags,
fields: {
expression: config.fields?.word?.trim(),
audio: (config.fields?.wordAudio || config.fields?.audio)?.trim(),
sentence: config.fields?.sentence?.trim(),
screenshot: config.fields?.image?.trim(),
},
model: config.isLapis?.enabled ? config.isLapis.sentenceCardModel?.trim() : undefined,
family: config.isLapis?.enabled
? 'lapis'
: config.isKiku?.enabled
? 'kiku'
: config.isSenren?.enabled
? 'senren'
: undefined,
};
}
// Installed inside the hidden Hachidori settings page. Uses local option writes
// even when dictionaries are remote or their host is offline.
export const HACHIDORI_ANKI_SETTINGS_SCRIPT = String.raw`
globalThis.__subminerSyncAnkiSettings = async ({ server, deck, forceOverride, hints }) => {
const previousProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? server : null);
const { applyAnkiPreset, resolveAnkiTemplates } = await import('./anki-templates.js');
const { ankiSetupFamily } = await import('./anki-setup.js');
const { createAnkiGateway } = await import('./anki.js');
const gateway = createAnkiGateway({ timeoutMs: 2000 });
const discoveries = new Map();
const discover = async (anki, model) => {
const key = JSON.stringify([anki.url, anki.apiKey, model]);
if (!discoveries.has(key)) discoveries.set(key, gateway.discover({ ...anki, model }));
return discoveries.get(key);
};
for (let attempt = 0; attempt < 2; attempt += 1) {
const options = await readOptions();
let anki = options.anki;
const canReplaceServer = forceOverride || !anki.url || anki.url === server
|| anki.url === 'http://127.0.0.1:8765' || anki.url === previousProxy;
if (!canReplaceServer) return { updated: false, matched: false, reason: 'blocked-existing-server' };
anki = { ...anki, url: server };
const first = anki.templates[0];
if (!first) return { updated: false, matched: false, reason: 'no-templates' };
let template = { ...first, fields: { ...first.fields } };
const pristine = !first.model && first.fieldTemplates === null
&& Object.values(first.fields).every(value => !value);
if (deck && (!first.deck || (pristine && first.deck === 'Default'))) template.deck = deck;
if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) {
template.tags = [...hints.tags];
}
let pending = false;
// Advanced templates include intentionally blank fields. Preserve them
// as a unit rather than replacing them with inferred mappings.
if (hints && first.fieldTemplates === null) {
let discovery = await discover(anki, template.model || hints.model || '');
if (!discovery.connected) {
pending = true;
} else {
if (!template.model) {
const candidates = hints.model
? discovery.models.filter(model => model === hints.model)
: discovery.models.filter(model => !hints.family || ankiSetupFamily(model) === hints.family);
const anchors = [hints.fields.expression, hints.fields.sentence].filter(Boolean);
const matches = [];
// Require a configured word and sentence field for an inferred
// model. Never choose the first of several compatible note types.
if (anchors.length === 2) for (const model of candidates) {
const result = await discover(anki, model);
if (!result.connected || result.errors.length) { pending = true; break; }
if (anchors.every(field => result.fields.includes(field))) matches.push(result);
}
if (!pending && matches.length === 1) {
discovery = matches[0];
template.model = discovery.model;
}
}
if (template.model && discovery.model === template.model && discovery.fields.length) {
const fields = discovery.fields;
for (const [semantic, field] of Object.entries(hints.fields)) {
if (!template.fields[semantic] && field && fields.includes(field)) template.fields[semantic] = field;
}
if (Object.values(first.fields).every(value => !value)) {
const preset = applyAnkiPreset(template, fields, ankiSetupFamily(template.model) || 'automatic');
const configured = resolveAnkiTemplates(template, fields).templates;
const markers = new Set(Object.entries(template.fields)
.filter(([, value]) => value).map(([key]) => '{' + key + '}'));
for (const row of Object.values(preset.fieldTemplates)) {
for (const marker of markers) row.value = row.value.replaceAll(marker, '');
}
for (const [field, row] of Object.entries(configured)) {
if (row.value) preset.fieldTemplates[field] = row;
}
template = preset;
}
}
}
}
// Hachidori retains a compatibility projection of its first template.
// Updating both prevents its normalizer from restoring stale values.
const templateConfig = Object.fromEntries(globalThis.HDReaderOptions.ANKI_TEMPLATE_CONFIG_KEYS
.map(key => [key, template[key]]));
anki = { ...anki, ...templateConfig,
templates: [template, ...anki.templates.slice(1)] };
const changed = JSON.stringify(anki) !== JSON.stringify(options.anki);
try {
if (changed) await send('hd_options_write', {
baseRevision: options.revision, options: { anki },
}, 'hoshidicts-worker');
return { updated: changed, matched: !pending, pending };
} catch (error) {
if (attempt > 0) throw error;
// Re-read after a concurrent settings save before filling anything.
}
}
};
`;
@@ -0,0 +1,446 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
import * as vm from 'node:vm';
import { HACHIDORI_PARSER_BRIDGE_SCRIPT } from './hachidori-parser-bridge';
import {
requestYomitanScanTokens,
requestYomitanParseResults,
requestYomitanTermFrequencies,
syncYomitanDefaultAnkiServer,
addYomitanNoteViaSearch,
} from './yomitan-parser-runtime';
import { createDeps } from './yomitan-scan-test-harness';
import { selectYomitanParseTokens } from './parser-selection-stage';
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
const characterDictionary = 'SubMiner Character Dictionary (AniList 1)';
function lookupResult(matched: string, expression: string, reading: string, dictionary = 'JMdict') {
return {
matched,
deinflected: expression,
trace: [],
term: {
expression,
reading,
score: 0,
rules: 'v1',
glossaries: [{ dictionary, glossary: '["definition"]', termTags: '', definitionTags: '' }],
pitches: [],
frequencies: [{ dictionary: 'Frequency', frequencies: [{ value: 42, displayValue: '' }] }],
},
};
}
async function createHarness(emptyLibrary = false) {
const messages: Array<Record<string, unknown>> = [];
let dictionaryRevision = 2;
let optionRevision = 3;
let anki = {
url: 'http://127.0.0.1:8765',
templates: [
{ id: 'default', name: 'Default', deck: 'Old', model: 'Japanese', fields: {} },
{ id: 'second', name: 'Second', deck: 'Other', model: 'Japanese', fields: {} },
],
};
let dictionaries = [
{ id: 'terms', title: 'JMdict', enabled: true, revision: '1' },
{ id: 'names', title: characterDictionary, enabled: true, revision: '1' },
{
id: 'frequency',
title: 'Frequency',
enabled: true,
revision: '1',
frequencyMode: 'rank-based',
},
];
let duplicate = false;
let proxyUrl: unknown;
let loadingStatusReplies = 0;
let busyEngineReplies = 0;
const apiModule: unknown = await import(
pathToFileURL(path.join(extensionPath, 'api-host.js')).href
);
const context = vm.createContext({
__testApiModule: apiModule,
setTimeout,
crypto,
URL,
Blob,
Uint8Array,
atob,
chrome: {
storage: {
local: {
set: async (value: Record<string, unknown>) => {
proxyUrl = value.subminerAnkiProxyUrl;
},
get: async () => ({
options: { anki, scanLength: 40, revision: optionRevision },
subminerAnkiProxyUrl: proxyUrl,
}),
},
},
runtime: {
getManifest: () => ({ version: 'test' }),
sendMessage: async (message: Record<string, unknown>) => {
messages.push(structuredClone(message));
if (message.type === 'hd_status') {
const loading = loadingStatusReplies > 0;
if (loading) loadingStatusReplies -= 1;
return { ok: true, ready: !loading, loading };
}
if (busyEngineReplies > 0 && message.target !== 'hachidori-anki') {
busyEngineReplies -= 1;
return { ok: false, error: 'the dictionary engine is busy mutating' };
}
switch (message.type) {
case 'hd_state_read':
return {
ok: true,
state: emptyLibrary ? null : { revision: dictionaryRevision, dictionaries },
};
case 'hd_lookup': {
const text = String(message.text);
const candidates = [
lookupResult('食べた', '食べる', 'たべる'),
lookupResult('食べる', '食べる', 'たべる'),
lookupResult('ミナト', 'ミナト', 'みなと', characterDictionary),
];
return {
ok: true,
generation: 1,
results: candidates.filter((result) => text.startsWith(result.matched)),
};
}
case 'hd_frequencies':
return {
ok: true,
frequencies: [
{
term: '食べる',
reading: 'たべる',
hasReading: true,
dictionary: 'Frequency',
frequency: 42,
displayValue: null,
},
],
};
case 'hd_options_write': {
if (message.baseRevision !== optionRevision) return { ok: false, error: 'conflict' };
const update = message.options;
assert.ok(update && typeof update === 'object' && 'anki' in update);
const value = update.anki;
assert.ok(
value &&
typeof value === 'object' &&
'url' in value &&
typeof value.url === 'string',
);
assert.ok('templates' in value && Array.isArray(value.templates));
anki = { url: value.url, templates: value.templates };
optionRevision += 1;
return { ok: true };
}
case 'hd_apply_state': {
assert.equal(message.baseRevision, dictionaryRevision);
assert.ok(Array.isArray(message.dictionaries));
dictionaries = message.dictionaries;
dictionaryRevision += 1;
return { ok: true };
}
case 'hd_anki_status':
return { ok: true, configKey: 'configuration' };
case 'hd_anki_preflight':
return {
ok: true,
canAdd: !duplicate,
state: duplicate ? 'duplicate' : 'addable',
noteIds: duplicate ? [15] : [],
};
case 'hd_anki_submit':
return { ok: true, state: 'added', noteId: 19 };
case 'hd_import':
return { ok: true, report: { success: true } };
case 'hd_remove':
return { ok: true };
default:
throw new Error('Unexpected native request: ' + String(message.type));
}
},
},
},
});
vm.runInContext('globalThis.window = globalThis', context);
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
const script = HACHIDORI_PARSER_BRIDGE_SCRIPT.replace(
"await import('./api-host.js')",
'__testApiModule',
).replace("await import('./reader-options.js')", 'Promise.resolve()');
await vm.runInContext(script, context);
const run = async (code: string): Promise<unknown> =>
structuredClone(await vm.runInContext(code, context));
const invoke = (action: string, params?: unknown) =>
run(`new Promise((resolve, reject) => {
__subminerDictionarySendMessage(${JSON.stringify({ action, params })}, response => {
if (response.error) reject(new Error(response.error.message)); else resolve(response.result);
});
})`);
return {
deps: createDeps(run),
run,
invoke,
messages,
anki: () => anki,
proxyUrl: () => proxyUrl,
setAnkiServer: (url: string) => {
anki = { ...anki, url };
},
setAnkiTemplates: (templates: typeof anki.templates) => {
anki = { ...anki, templates };
},
dictionaries: () => dictionaries,
disableDictionary: (id: string) => {
dictionaries = dictionaries.map((entry) =>
entry.id === id ? { ...entry, enabled: false } : entry,
);
dictionaryRevision += 1;
},
setDuplicate: () => {
duplicate = true;
},
changeRevision: () => {
optionRevision += 1;
},
setLoadingStatusReplies: (count: number) => {
loadingStatusReplies = count;
},
setBusyEngineReplies: (count: number) => {
busyEngineReplies = count;
},
};
}
test('Hachidori runs the shared scanner with inflected offsets, headwords, names and frequencies', async () => {
const harness = await createHarness();
const tokens = await requestYomitanScanTokens(
'ミナト 食べた',
harness.deps,
{ error: assert.fail },
{
includeNameMatchMetadata: true,
currentCharacterDictionaryMediaId: 1,
},
);
assert.ok(tokens);
assert.equal(tokens[0]?.surface, 'ミナト');
assert.equal(tokens[0]?.isNameMatch, true);
assert.equal(tokens[1]?.surface, '食べた');
assert.equal(tokens[1]?.headword, '食べる');
assert.equal(tokens[1]?.startPos, 4);
assert.equal(tokens[1]?.endPos, 7);
assert.equal(tokens[1]?.frequencyRank, 42);
assert.deepEqual(tokens[1]?.wordClasses, ['v1']);
const frequencies = await requestYomitanTermFrequencies(
[{ term: '食べる', reading: 'たべる' }],
harness.deps,
{ error: assert.fail },
);
assert.equal(frequencies[0]?.frequency, 42);
assert.equal(frequencies[0]?.dictionary, 'Frequency');
});
test('Hachidori syncs the Anki endpoint and every term template through revisioned writes', async () => {
const harness = await createHarness();
const synced = await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{ deck: 'Mining' },
);
assert.equal(synced, true);
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
assert.deepEqual(
harness.anki().templates.map((template) => template.deck),
['Mining', 'Mining'],
);
assert.equal(harness.proxyUrl(), null);
assert.equal(
await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{ forceOverride: true },
),
true,
);
assert.equal(harness.proxyUrl(), 'http://127.0.0.1:8766');
await assert.rejects(
harness.invoke('setAllSettings', {
value: {
hachidoriRevisions: { dictionaries: 99, options: 99 },
profiles: [{ options: { dictionaries: [], anki: { server: '', cardFormats: [] } } }],
},
}),
/settings changed/,
);
});
test('Hachidori applies only SubMiner changes when its settings moved on meanwhile', async () => {
const harness = await createHarness();
const projected = (await harness.invoke('optionsGetFull')) as {
profiles: Array<{ options: { dictionaries: Array<{ name: string; enabled: boolean }> } }>;
};
const jmdict = projected.profiles[0]!.options.dictionaries.find(
(entry) => entry.name === 'JMdict',
);
jmdict!.enabled = false;
// Hachidori changed both revisions after SubMiner read them.
harness.disableDictionary('names');
harness.setAnkiServer('http://192.168.1.10:8765');
harness.changeRevision();
assert.equal(await harness.invoke('setAllSettings', { value: projected }), true);
assert.deepEqual(
harness.dictionaries().map((entry) => [entry.id, entry.enabled]),
[
['terms', false],
['names', false],
['frequency', true],
],
);
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
assert.equal(harness.messages.filter((message) => message.type === 'hd_options_write').length, 0);
});
test('Hachidori settings writes survive an empty template list', async () => {
const harness = await createHarness();
harness.setAnkiTemplates([]);
assert.equal(
await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{
forceOverride: true,
},
),
true,
);
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
});
test('Hachidori initializes an empty library before the first dictionary import', async () => {
const harness = await createHarness(true);
assert.deepEqual(await harness.invoke('getDictionaryInfo'), []);
assert.equal(
await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{ forceOverride: true },
),
true,
);
});
test('Hachidori restores direct AnkiConnect when disabling its managed proxy', async () => {
const harness = await createHarness();
const logger = { error: assert.fail };
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
forceOverride: true,
}),
true,
);
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
true,
);
assert.equal(harness.anki().url, 'http://127.0.0.1:8765');
assert.equal(harness.proxyUrl(), null);
});
test('Hachidori preserves a custom Anki endpoint when disabling its managed proxy', async () => {
const harness = await createHarness();
const logger = { error: assert.fail };
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
forceOverride: true,
}),
true,
);
harness.setAnkiServer('http://192.168.1.10:8765');
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
false,
);
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
assert.equal(harness.proxyUrl(), null);
});
test('Hachidori fallback parsing retains token boundaries, inflected readings and headwords', async () => {
const harness = await createHarness();
const parsed = await requestYomitanParseResults('ミナト 食べた', harness.deps, {
error: assert.fail,
});
const tokens = selectYomitanParseTokens(parsed, () => false, 'headword');
assert.deepEqual(
tokens?.map((token) => ({
surface: token.surface,
headword: token.headword,
reading: token.reading,
start: token.startPos,
})),
[
{ surface: 'ミナト', headword: 'ミナト', reading: 'みなと', start: 0 },
{ surface: '食べた', headword: '食べる', reading: 'たべた', start: 4 },
],
);
});
test('Hachidori stats mining returns note IDs and prevents duplicate submissions', async () => {
const harness = await createHarness();
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
noteId: 19,
duplicateNoteIds: [],
});
harness.setDuplicate();
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
noteId: null,
duplicateNoteIds: [15],
});
assert.equal(harness.messages.filter((message) => message.type === 'hd_anki_submit').length, 1);
});
test('Hachidori settings automation imports ZIP bytes and removes the matching dictionary ID', async () => {
const harness = await createHarness();
await harness.run(
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEs=', 'characters.zip')",
);
await harness.run(
`__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`,
);
const imported = harness.messages.find((message) => message.type === 'hd_import');
assert.equal(imported?.fileName, 'characters.zip');
assert.match(String(imported?.blobUrl), /^blob:/);
const removed = harness.messages.find((message) => message.type === 'hd_remove');
assert.equal(removed?.id, 'names');
});
test('Hachidori waits for a loading engine and retries busy requests before answering', async () => {
const harness = await createHarness();
harness.setLoadingStatusReplies(2);
harness.setBusyEngineReplies(1);
const dictionaries = (await harness.invoke('getDictionaryInfo')) as Array<{ title: string }>;
assert.equal(dictionaries.length, 3);
const statusCalls = harness.messages.filter((message) => message.type === 'hd_status').length;
assert.ok(statusCalls >= 3, `expected repeated status polls, saw ${statusCalls}`);
const tokens = await requestYomitanScanTokens('食べた', harness.deps, { error: assert.fail });
assert.equal(tokens?.[0]?.headword, '食べる');
assert.equal(tokens?.[0]?.frequencyRank, 42);
});
@@ -0,0 +1,262 @@
// Hachidori exposes its own storage and engine protocol. Adapt it only inside
// SubMiner's hidden extension windows so the shared scanner keeps its matching,
// character-name and frequency semantics without changing Hachidori's pages.
import { HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
export const HACHIDORI_SESSION_PARTITION = 'persist:hachidori';
export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
(async () => {
if (globalThis.__subminerDictionarySendMessage) return;
const { createApiHost } = await import('./api-host.js');
await import('./reader-options.js');
const send = async (type, fields = {}, target = 'hoshidicts-offscreen') => {
const reply = await chrome.runtime.sendMessage({
target, type, requestId: crypto.randomUUID(), ...fields,
});
if (!reply || reply.ok !== true) {
throw new Error(reply?.error || 'Hachidori returned an invalid response');
}
return reply;
};
// Hachidori reports an empty library while its dictionaries load and rejects
// engine requests during imports. Wait for a settled engine so SubMiner never
// caches an empty dictionary list or a token-less scan.
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const waitForEngineReady = async () => {
const deadline = Date.now() + 120000;
for (;;) {
const reply = await send('hd_status').catch(() => null);
if (reply?.ready === true && reply.loading !== true) return;
if (Date.now() >= deadline) {
throw new Error(reply?.ready === true
? 'Hachidori is still importing dictionaries' : 'Hachidori dictionary engine is not ready');
}
await sleep(250);
}
};
const engine = async (type, fields = {}, target) => {
for (let attempt = 0; ; attempt += 1) {
await waitForEngineReady();
try {
return await send(type, fields, target);
} catch (error) {
if (attempt >= 3 || !/busy mutating/.test(error.message)) throw error;
await sleep(250);
}
}
};
const readState = async () => (await engine('hd_state_read', {}, 'hoshidicts-worker')).state
?? { revision: 0, dictionaries: [] };
const readOptions = async () => {
const stored = (await chrome.storage.local.get('options')).options;
return { ...globalThis.HDReaderOptions.normaliseOptions(stored), revision: stored?.revision ?? 0 };
};
globalThis.__subminerSetAnkiProxyUrl = async url => {
const previous = (await chrome.storage.local.get('subminerAnkiProxyUrl')).subminerAnkiProxyUrl;
await chrome.storage.local.set({ subminerAnkiProxyUrl: url });
return typeof previous === 'string' ? previous : null;
};
${HACHIDORI_ANKI_SETTINGS_SCRIPT}
const api = createApiHost({
engine: fields => engine(fields.type, fields),
render: fields => send(fields.type, fields, 'hachidori-anki-render'),
readDictionaries: async () => (await readState()).dictionaries,
readAudioSources: async () => (await readOptions()).audioSources.filter(source => source.enabled),
version: chrome.runtime.getManifest().version,
});
// Snapshot of the last projection SubMiner received, so a later write can
// be reduced to the fields SubMiner actually changed.
let lastProjection = null;
async function optionsGetFull() {
const [state, options] = await Promise.all([readState(), readOptions()]);
const revisions = { dictionaries: state.revision, options: options.revision };
lastProjection = {
revisions,
enabledByTitle: Object.fromEntries(state.dictionaries.map(entry => [entry.title, entry.enabled !== false])),
server: options.anki.url,
deckByFormat: Object.fromEntries(options.anki.templates.map(template => [template.id, template.deck])),
};
return {
profileCurrent: 0,
hachidoriRevisions: revisions,
profiles: [{ name: 'Hachidori', options: {
scanning: { length: options.scanLength },
dictionaries: state.dictionaries.map((entry, index) => ({
name: entry.title, alias: entry.displayName || entry.title,
id: index, enabled: entry.enabled !== false,
})),
anki: {
server: options.anki.url,
cardFormats: options.anki.templates.map(template => ({
id: template.id, type: 'term', enabled: true, deck: template.deck,
})),
},
} }],
};
}
async function setAllSettings(value) {
const projected = value.profiles?.[0]?.options;
const revisions = value.hachidoriRevisions;
if (!projected || !revisions) throw new Error('Invalid Hachidori settings projection');
const base = lastProjection
&& lastProjection.revisions.dictionaries === revisions.dictionaries
&& lastProjection.revisions.options === revisions.options ? lastProjection : null;
const enabledByTitle = Object.fromEntries(projected.dictionaries.map(item => [item.name, item.enabled === true]));
const deckByFormat = Object.fromEntries(projected.anki.cardFormats.map(format => [format.id, format.deck]));
for (let attempt = 0; ; attempt += 1) {
const [state, options] = await Promise.all([readState(), readOptions()]);
const current = state.revision === revisions.dictionaries && options.revision === revisions.options;
if (!current && !base) {
throw new Error('Hachidori settings changed while SubMiner was updating them; retry the action');
}
// Once Hachidori has moved on, apply only SubMiner's own changes on top
// of the newer settings instead of replaying the stale projection.
const enabledFor = title => enabledByTitle[title] === true;
const dictionaries = state.dictionaries.map(entry =>
current || (base.enabledByTitle[entry.title] === true) !== enabledFor(entry.title)
? { ...entry, enabled: enabledFor(entry.title) } : entry);
const templates = options.anki.templates.map(template => {
const deck = deckByFormat[template.id];
return deck !== undefined && (current || base.deckByFormat[template.id] !== deck)
? { ...template, deck } : template;
});
const url = current || base.server !== projected.anki.server ? projected.anki.server : options.anki.url;
const anki = { ...options.anki, url, templates, deck: templates[0]?.deck ?? options.anki.deck };
try {
if (JSON.stringify(dictionaries) !== JSON.stringify(state.dictionaries)) {
await engine('hd_apply_state', { baseRevision: state.revision, dictionaries });
}
if (JSON.stringify(anki) !== JSON.stringify(options.anki)) {
await engine('hd_options_write', { baseRevision: options.revision, options: { anki } }, 'hoshidicts-worker');
}
return true;
} catch (error) {
// A write raced another Hachidori change; re-read once and reapply the delta.
if (attempt > 0 || !base) throw error;
}
}
}
async function getTermFrequencies({ termReadingList, dictionaries }) {
let frequencies;
try {
({ frequencies } = await engine('hd_frequencies', { termReadingList }));
} catch (error) {
const { sharing } = await send('hd_sharing_status', {}, 'hachidori-sharing');
if (!sharing?.client?.connected || !/unknown|unsupported|not supported/i.test(error.message)) throw error;
// Older external hosts expose frequency data through term lookups only.
frequencies = [];
for (const { term, reading } of termReadingList) {
const { results } = await engine('hd_lookup', { text: term, maxResults: 100 });
for (const result of results) {
if (result.term.expression !== term || (reading !== null && result.term.reading !== reading)) continue;
for (const group of result.term.frequencies) {
for (const value of group.frequencies) {
frequencies.push({ term, reading: value.reading || null,
hasReading: typeof value.reading === 'string' && value.reading.length > 0,
dictionary: group.dictionary, frequency: value.value,
displayValue: value.displayValue || null, displayValueParsed: false });
}
}
}
}
}
return frequencies.filter(frequency => dictionaries.includes(frequency.dictionary));
}
// Hachidori's public tokenize API emits display furigana without headwords.
// SubMiner's fallback requires one group per token and a dictionary form.
async function parseText({ text, scanLength }) {
const content = [];
let position = 0;
while (position < text.length) {
const rest = text.slice(position);
const reply = await engine('hd_lookup', { text: rest, maxResults: 1, scanLength });
const result = reply.results[0];
if (!result?.matched || !rest.startsWith(result.matched)) {
const character = String.fromCodePoint(rest.codePointAt(0));
content.push([{ text: character, reading: '' }]);
position += character.length;
continue;
}
const term = result.term;
// Keep the inflected ending in the reading, just as the main scanner does.
let stem = 0;
while (stem < term.expression.length && stem < result.matched.length && term.expression[stem] === result.matched[stem]) stem += 1;
const ending = term.expression.slice(stem);
const reading = stem > 0 && term.reading.endsWith(ending)
? term.reading.slice(0, term.reading.length - ending.length) + result.matched.slice(stem)
: term.reading;
content.push([{ text: result.matched, reading, headwords: [[{ term: term.expression }]] }]);
position += result.matched.length;
}
return [{ source: 'scanning-parser', index: 0, content }];
}
async function invoke(action, params) {
switch (action) {
case 'optionsGetFull': return optionsGetFull();
case 'setAllSettings': return setAllSettings(params.value);
case 'getDictionaryInfo': return (await readState()).dictionaries.map(entry => ({
title: entry.title, revision: entry.revision, frequencyMode: entry.frequencyMode,
}));
case 'termsFind': {
const reply = await api({ type: 'hd_api_term_entries', terms: [params.text] });
return reply.results[0];
}
case 'parseText': return parseText(params);
case 'getTermFrequencies': return getTermFrequencies(params);
default: throw new Error('Unsupported Hachidori parser action: ' + action);
}
}
globalThis.__subminerDictionarySendMessage = ({ action, params }, callback) => {
void invoke(action, params).then(result => callback({ result }), error => callback({ error: { message: error.message } }));
};
async function importArchive(blob, fileName) {
const blobUrl = URL.createObjectURL(blob);
try {
const reply = await engine('hd_import', { blobUrl, fileName });
if (reply.report?.success !== true) throw new Error(reply.report?.error || 'Hachidori dictionary import failed');
} finally {
URL.revokeObjectURL(blobUrl);
}
}
globalThis.__subminerYomitanSettingsAutomation = {
ready: true,
async importDictionaryArchiveUrl(url) {
const response = await fetch(url);
if (!response.ok) throw new Error('Could not read the dictionary archive');
await importArchive(await response.blob(), 'subminer-dictionary.zip');
},
async importDictionaryArchiveBase64(base64, fileName) {
const bytes = Uint8Array.from(atob(base64), character => character.charCodeAt(0));
await importArchive(new Blob([bytes], { type: 'application/zip' }), fileName);
},
async deleteDictionary(title) {
const dictionary = (await readState()).dictionaries.find(entry => entry.title === title);
if (dictionary) await engine('hd_remove', { id: dictionary.id, title });
},
};
globalThis.__subminerAddNote = async word => {
const lookup = await engine('hd_lookup', { text: word, maxResults: 1 });
const result = lookup.results[0];
if (!result) return { noteId: null, duplicateNoteIds: [] };
const status = await send('hd_anki_status', {}, 'hachidori-anki');
const request = {
...result, generation: lookup.generation, sentence: word, searchQuery: word,
matchOffset: 0, documentTitle: 'SubMiner', popupSelectionText: '',
configKey: status.configKey,
captureUnavailable: ['screenshot', 'animation', 'audio'],
};
const preflight = await send('hd_anki_preflight', { request }, 'hachidori-anki');
if (preflight.canAdd !== true) {
if (preflight.state !== 'duplicate') throw new Error(preflight.error || 'Hachidori Anki mining is unavailable');
return { noteId: null, duplicateNoteIds: preflight.noteIds ?? [] };
}
const submitted = await send('hd_anki_submit', { request }, 'hachidori-anki');
if (submitted.state === 'added' || submitted.state === 'updated') {
return { noteId: submitted.noteId, duplicateNoteIds: [] };
}
if (submitted.state === 'duplicate') return { noteId: null, duplicateNoteIds: submitted.noteIds ?? [] };
throw new Error(submitted.error || 'Hachidori could not confirm the note write');
};
})();
`;
@@ -1,7 +1,18 @@
import type { BrowserWindow, Extension, Session } from 'electron';
import type { AnkiConnectConfig } from '../../../types';
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
import {
buildHachidoriSharingScript,
parseHachidoriHostStatus,
type HachidoriSharingRequest,
} from '../../../shared/hachidori-sharing';
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
import {
HACHIDORI_PARSER_BRIDGE_SCRIPT,
HACHIDORI_SESSION_PARTITION,
} from './hachidori-parser-bridge';
import { selectYomitanParseTokens } from './parser-selection-stage';
import {
buildYomitanScanCallScript,
@@ -477,7 +488,7 @@ async function requestYomitanProfileMetadata(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -596,6 +607,18 @@ function logYomitanProfileDiagnostics(
logger.info?.('Yomitan active profile dictionaries loaded.', details);
}
function isHachidoriExtension(extension: Extension): boolean {
return extension.name === 'Hachidori';
}
// Without an explicit session, use the one the extension was loaded into:
// Hachidori lives in its own partition, Yomitan in the default session.
function resolveBackendSession(electron: typeof import('electron'), extension: Extension): Session {
return isHachidoriExtension(extension)
? electron.session.fromPartition(HACHIDORI_SESSION_PARTITION)
: electron.session.defaultSession;
}
async function ensureYomitanParserWindow(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
@@ -606,19 +629,20 @@ async function ensureYomitanParserWindow(
return false;
}
const currentWindow = deps.getYomitanParserWindow();
if (currentWindow && !currentWindow.isDestroyed()) {
return true;
}
const existingInitPromise = deps.getYomitanParserInitPromise();
if (existingInitPromise) {
return existingInitPromise;
}
const currentWindow = deps.getYomitanParserWindow();
if (currentWindow && !currentWindow.isDestroyed()) {
return true;
}
const initPromise = (async () => {
const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const { BrowserWindow } = electron;
const yomitanSession =
deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
const parserWindow = new BrowserWindow({
show: false,
width: 800,
@@ -649,11 +673,15 @@ async function ensureYomitanParserWindow(
});
try {
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`);
const parserPage = isHachidoriExtension(yomitanExt) ? 'settings.html' : 'search.html';
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/${parserPage}`);
const readyPromise = deps.getYomitanParserReadyPromise();
if (readyPromise) {
await readyPromise;
}
if (isHachidoriExtension(yomitanExt)) {
await parserWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
}
// Eagerly install the scan runtime so the first subtitle line does not
// pay the install round trip; failures fall back to the per-request
// install-and-retry path.
@@ -696,8 +724,8 @@ async function createYomitanExtensionWindow(
return null;
}
const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const { BrowserWindow } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
const window = new BrowserWindow({
show: false,
width: 1200,
@@ -740,6 +768,10 @@ async function invokeYomitanSettingsAutomation<T>(
}
try {
const extension = deps.getYomitanExt();
if (extension && isHachidoriExtension(extension)) {
await settingsWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
}
await settingsWindow.webContents.executeJavaScript(
`
(async () => {
@@ -892,7 +924,7 @@ export async function requestYomitanParseResults(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -922,7 +954,7 @@ export async function requestYomitanParseResults(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1079,7 +1111,7 @@ async function fetchYomitanTermFrequencies(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1122,7 +1154,7 @@ async function fetchYomitanTermFrequencies(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1206,7 +1238,17 @@ function cacheFrequencyEntriesForPairs(
const key = makeTermReadingCacheKey(pair.term, pair.reading);
const exactEntries = groupedByPair.get(key);
const termEntries = groupedByTerm.get(pair.term) ?? [];
frequencyCache.set(key, exactEntries ?? termEntries);
// Untagged frequency rows apply to every reading. A term-only query must
// retain all readings, rather than selecting only its untagged rows.
const untaggedEntries = groupedByPair.get(makeTermReadingCacheKey(pair.term, null)) ?? [];
frequencyCache.set(
key,
pair.reading === null
? termEntries
: exactEntries
? [...exactEntries, ...untaggedEntries]
: termEntries,
);
}
}
@@ -1340,6 +1382,7 @@ export async function syncYomitanDefaultAnkiServer(
options?: {
forceOverride?: boolean;
deck?: string;
ankiConfig?: AnkiConnectConfig;
},
): Promise<boolean> {
const normalizedTargetServer = serverUrl.trim();
@@ -1359,7 +1402,7 @@ export async function syncYomitanDefaultAnkiServer(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1379,6 +1422,16 @@ export async function syncYomitanDefaultAnkiServer(
const targetServer = ${JSON.stringify(normalizedTargetServer)};
const targetDeck = ${JSON.stringify(normalizedTargetDeck)};
const forceOverride = ${forceOverride ? 'true' : 'false'};
const hachidoriHints = ${JSON.stringify(options?.ankiConfig ? buildHachidoriAnkiHints(options.ankiConfig) : null)};
if (hachidoriHints && typeof globalThis.__subminerSyncAnkiSettings === 'function') {
return globalThis.__subminerSyncAnkiSettings({
server: targetServer, deck: targetDeck, forceOverride, hints: hachidoriHints,
});
}
let previousManagedProxy = null;
if (typeof globalThis.__subminerSetAnkiProxyUrl === 'function') {
previousManagedProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? targetServer : null);
}
const optionsFull = await invoke("optionsGetFull", undefined);
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
if (profiles.length === 0) {
@@ -1405,7 +1458,8 @@ export async function syncYomitanDefaultAnkiServer(
let changed = false;
if (currentServer !== targetServer) {
const canReplaceCurrent =
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765" ||
(typeof previousManagedProxy === 'string' && currentServer === previousManagedProxy);
if (!canReplaceCurrent) {
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
}
@@ -1455,6 +1509,12 @@ export async function syncYomitanDefaultAnkiServer(
try {
const result = await parserWindow.webContents.executeJavaScript(script, true);
if (isObject(result) && result.pending === true) {
logger.info?.(
'Anki is unavailable; Hachidori field auto-population will retry when opened again',
);
return false;
}
const updated =
typeof result === 'object' &&
result !== null &&
@@ -1564,7 +1624,7 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1586,6 +1646,25 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
`;
}
export async function requestHachidoriSharing(
request: HachidoriSharingRequest,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
) {
const extension = deps.getYomitanExt();
if (!extension || !isHachidoriExtension(extension)) throw new Error('Hachidori is not active.');
const ready = await ensureYomitanParserWindow(deps, logger);
const window = deps.getYomitanParserWindow();
if (!ready || !window || window.isDestroyed()) throw new Error('Hachidori is unavailable.');
const reply: unknown = await window.webContents.executeJavaScript(
buildHachidoriSharingScript(request),
true,
);
const status = parseHachidoriHostStatus(reply);
if (request.type !== 'hd_sharing_status') clearYomitanParserCachesForWindow(window);
return status;
}
async function invokeYomitanBackendAction<T>(
action: string,
params: unknown,
@@ -12,7 +12,7 @@ export type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
// Bump whenever the install script below changes so already-loaded parser
// windows re-install the new scan runtime instead of running the stale one.
export const YOMITAN_SCAN_RUNTIME_VERSION = 12;
export const YOMITAN_SCAN_RUNTIME_VERSION = 13;
export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
export interface YomitanScanRequestParams {
@@ -43,7 +43,7 @@ export const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
}
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
+3 -2
View File
@@ -16,6 +16,7 @@ type ExtensionCopyResult = {
type ExtensionCopyOptions = {
platform?: NodeJS.Platform;
extensionName?: 'yomitan' | 'hachidori';
};
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
@@ -156,7 +157,7 @@ export function ensureExtensionCopy(
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
let shouldCopy = !fs.existsSync(targetDir);
if (!shouldCopy) {
@@ -182,7 +183,7 @@ export async function ensureExtensionCopyAsync(
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
const inFlightKey = path.resolve(targetDir);
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
if (inFlight) {
+9 -5
View File
@@ -7,6 +7,7 @@ const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = el
const logger = createLogger('main:yomitan-settings');
export interface OpenYomitanSettingsWindowOptions {
backend?: 'yomitan' | 'hachidori';
yomitanExt: Extension | null;
getExistingWindow: () => BrowserWindow | null;
setWindow: (window: BrowserWindow | null) => void;
@@ -23,6 +24,7 @@ type HyprlandSessionEnv = {
export interface InstallYomitanSettingsCloseButtonOptions {
platform?: NodeJS.Platform;
env?: HyprlandSessionEnv;
backend?: 'yomitan' | 'hachidori';
}
export function shouldInstallYomitanSettingsCloseButton(
@@ -53,7 +55,9 @@ export function buildYomitanSettingsWindowMenuTemplate(
];
}
export function buildYomitanSettingsCloseButtonScript(): string {
export function buildYomitanSettingsCloseButtonScript(
backend: 'yomitan' | 'hachidori' = 'yomitan',
): string {
return `
(() => {
const buttonId = 'subminer-yomitan-settings-close';
@@ -97,7 +101,7 @@ export function buildYomitanSettingsCloseButtonScript(): string {
button.id = buttonId;
button.type = 'button';
button.title = 'Close';
button.setAttribute('aria-label', 'Close Yomitan settings');
button.setAttribute('aria-label', 'Close ${backend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings');
button.textContent = '\\u00d7';
button.addEventListener('click', () => {
window.close();
@@ -118,7 +122,7 @@ export function installYomitanSettingsCloseButton(
return;
}
settingsWindow.webContents
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
.executeJavaScript(buildYomitanSettingsCloseButtonScript(options.backend))
.catch((error: Error) => {
logger.warn('Failed to install Yomitan settings close button:', error.message);
});
@@ -184,7 +188,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
const settingsWindow = new ElectronBrowserWindow({
title: 'Yomitan Settings',
title: options.backend === 'hachidori' ? 'Hachidori Settings' : 'Yomitan Settings',
width: 1200,
height: 800,
show: false,
@@ -228,7 +232,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
settingsWindow.webContents.on('did-finish-load', () => {
logger.info('Settings page loaded successfully');
installYomitanSettingsCloseButton(settingsWindow);
installYomitanSettingsCloseButton(settingsWindow, { backend: options.backend });
});
setTimeout(() => {
+162 -7
View File
@@ -1,3 +1,4 @@
import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime';
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2026 sudacode
@@ -32,6 +33,14 @@ import {
screen,
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import {
createHachidoriExtensionRuntime,
getHachidoriSession,
} from './core/services/hachidori-extension';
import {
DICTIONARY_EXTERNAL_LINK_CHANNEL,
parseDictionaryExternalUrl,
} from './shared/dictionary-external-link';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
@@ -906,14 +915,34 @@ const {
appState,
appLifecycleApp,
} = bootServices;
// Backend changes take effect on restart; windows and parser must share one session.
const activeDictionaryBackend = configService.getConfig().dictionaryBackend;
const hachidoriExtensionRuntime = createHachidoriExtensionRuntime(USER_DATA_PATH);
let hachidoriSettingsWindow: BrowserWindow | null = null;
let inactiveYomitanExtension: Extension | null = null;
let inactiveYomitanSettingsWindow: BrowserWindow | null = null;
let inactiveYomitanLoad: Promise<Extension | null> | null = null;
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => {
if (
activeDictionaryBackend !== 'hachidori' ||
event.senderFrame !== event.sender.mainFrame ||
!overlayManager.getOverlayWindows().some((window) => window.webContents === event.sender)
) {
throw new Error('Dictionary links are only available from the active overlay');
}
await shell.openExternal(parseDictionaryExternalUrl(value));
});
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
return [
appState.configSettingsWindow,
appState.syncUiWindow,
appState.yomitanSettingsWindow,
hachidoriSettingsWindow,
inactiveYomitanSettingsWindow,
appState.anilistSetupWindow,
appState.jellyfinSetupWindow,
appState.firstRunSetupWindow,
@@ -1011,6 +1040,8 @@ const {
} = statsServerRuntime;
function requestAppQuit(): void {
destroyYomitanSettingsWindow(hachidoriSettingsWindow);
destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow);
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
appState.yomitanSettingsWindow = null;
destroyStatsWindow();
@@ -1455,6 +1486,15 @@ const createCommandLineLauncherRuntimeOptions = () => ({
: undefined,
});
const firstRunSetupService = createFirstRunSetupService({
getDictionaryBackend: () => activeDictionaryBackend,
getHachidoriHostStatus: async () => {
await ensureYomitanExtensionLoaded();
return requestHachidoriSharing(
{ type: 'hd_sharing_status' },
getYomitanParserRuntimeDeps(),
logger,
);
},
platform: process.platform,
configDir: CONFIG_DIR,
getYomitanDictionaryCount: async () => {
@@ -2277,6 +2317,11 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) {
void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) =>
logger.error('Failed to auto-populate Hachidori Anki settings', error),
);
}
},
invalidateTokenizationCache: () => {
subtitleProcessingController.invalidateTokenizationCache();
@@ -3450,6 +3495,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
return {
configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount,
dictionaryBackend: snapshot.dictionaryBackend,
hachidoriHost: snapshot.hachidoriHost,
canFinish: snapshot.canFinish,
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
@@ -3465,6 +3512,33 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (submission: FirstRunSetupSubmission) => {
if (
submission.action === 'link-hachidori-host' ||
submission.action === 'unlink-hachidori-host'
) {
try {
if (activeDictionaryBackend !== 'hachidori')
throw new Error('Select Hachidori and restart SubMiner before linking a host.');
await ensureYomitanExtensionLoaded();
const status = await requestHachidoriSharing(
submission.action === 'link-hachidori-host'
? { type: 'hd_sharing_client_link', address: submission.address }
: { type: 'hd_sharing_client_unlink' },
getYomitanParserRuntimeDeps(),
logger,
);
firstRunSetupMessage =
status.kind === 'local'
? 'Using dictionaries installed in SubMiner.'
: status.kind === 'connected'
? `Linked to ${status.name}. Anki mining stays in SubMiner.`
: status.message;
} catch (error) {
firstRunSetupMessage =
error instanceof Error ? error.message : 'Could not update the dictionary host.';
}
return;
}
if (submission.action === 'remove-legacy-plugin') {
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
firstRunSetupMessage = snapshot.message;
@@ -3505,8 +3579,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
return;
}
if (submission.action === 'open-yomitan-settings') {
firstRunSetupMessage = openYomitanSettings()
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
firstRunSetupMessage = openDictionarySettings()
? `Opened ${activeDictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.`
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
@@ -5157,6 +5231,41 @@ function initializeOverlayRuntime(): void {
}
function openYomitanSettings(): boolean {
if (activeDictionaryBackend === 'hachidori') {
if (configService.getConfig().yomitan.externalProfilePath.trim()) {
logger.warn('Yomitan settings unavailable while using read-only external-profile mode.');
return false;
}
inactiveYomitanLoad ??= inactiveYomitanExtension
? Promise.resolve(inactiveYomitanExtension)
: loadYomitanExtensionCore({
userDataPath: USER_DATA_PATH,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: (extension) => {
inactiveYomitanExtension = extension;
},
setYomitanSession: () => {},
}).finally(() => {
inactiveYomitanLoad = null;
});
void (
inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad
)
.then((extension) =>
openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: () => inactiveYomitanSettingsWindow,
setWindow: (window) => {
inactiveYomitanSettingsWindow = window;
},
}),
)
.catch((error: unknown) => logger.error('Failed to open Yomitan settings', error));
return true;
}
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
logger.warn(
@@ -5169,6 +5278,39 @@ function openYomitanSettings(): boolean {
return true;
}
function openHachidoriSettings(): void {
void (
activeDictionaryBackend === 'hachidori'
? ensureYomitanExtensionLoaded()
: hachidoriExtensionRuntime.ensureLoaded()
)
.then((extension) =>
openYomitanSettingsWindow({
backend: 'hachidori',
yomitanExt: extension,
yomitanSession: getHachidoriSession(),
getExistingWindow: () => hachidoriSettingsWindow,
setWindow: (window) => {
hachidoriSettingsWindow = window;
},
onWindowClosed: () => {
if (activeDictionaryBackend === 'hachidori' && appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
}
},
}),
)
.catch((error: unknown) => logger.error('Failed to open Hachidori settings', error));
}
function openDictionarySettings(): boolean {
if (activeDictionaryBackend === 'hachidori') {
openHachidoriSettings();
return true;
}
return openYomitanSettings();
}
const { exportLogsFromTray } = createLogExportTrayRuntime({
flushMpvLog: () => flushMpvLog(),
logInfo: (message) => logger.info(message),
@@ -5196,7 +5338,7 @@ const {
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
registerGlobalShortcutsCore,
toggleVisibleOverlay: () => toggleVisibleOverlay(),
openYomitanSettings: () => openYomitanSettings(),
openYomitanSettings: () => openDictionarySettings(),
isDev,
getMainWindow: () => overlayManager.getMainWindow(),
}),
@@ -5687,7 +5829,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
}
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(),
openYomitanSettings: () => openDictionarySettings(),
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
// live mpv sub timings at lookup time so media generation clips the mined line even
// when extraction finishes long after playback has moved on.
@@ -6128,6 +6270,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
},
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
openHachidoriSettings: () => openHachidoriSettings(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
@@ -6303,7 +6446,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) =>
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
getYomitanSession: () => appState.yomitanSession,
getYomitanSession: () =>
activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession,
tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
@@ -6391,6 +6535,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
getDictionaryBackend: () => activeDictionaryBackend,
openHachidoriSettings: () => openHachidoriSettings(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
@@ -6435,12 +6581,21 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
});
const yomitanProfilePolicy = createYomitanProfilePolicy({
externalProfilePath: configService.getConfig().yomitan.externalProfilePath,
externalProfilePath:
activeDictionaryBackend === 'yomitan'
? configService.getConfig().yomitan.externalProfilePath
: '',
logInfo: (message) => logger.info(message),
});
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore,
loadYomitanExtensionCore: async (deps) => {
if (activeDictionaryBackend === 'yomitan') return loadYomitanExtensionCore(deps);
const extension = await hachidoriExtensionRuntime.ensureLoaded();
deps.setYomitanExtension(extension);
deps.setYomitanSession(getHachidoriSession());
return extension;
},
userDataPath: USER_DATA_PATH,
externalProfilePath: configuredExternalYomitanProfilePath,
getYomitanParserWindow: () => appState.yomitanParserWindow,
+2
View File
@@ -49,6 +49,7 @@ export interface CliCommandRuntimeServiceContext {
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -135,6 +136,7 @@ function createCliCommandDepsFromContext(
ui: {
openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings,
openHachidoriSettings: context.openHachidoriSettings,
openConfigSettingsWindow: context.openConfigSettingsWindow,
openSyncUiWindow: context.openSyncUiWindow,
cycleSecondarySubMode: context.cycleSecondarySubMode,
+2
View File
@@ -217,6 +217,7 @@ export interface CliCommandRuntimeServiceDepsParams {
ui: {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
openHachidoriSettings: CliCommandDepsRuntimeOptions['ui']['openHachidoriSettings'];
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
@@ -430,6 +431,7 @@ export function createCliCommandRuntimeServiceDeps(
ui: {
openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings,
openHachidoriSettings: params.ui.openHachidoriSettings,
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
openSyncUiWindow: params.ui.openSyncUiWindow,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
+54 -2
View File
@@ -18,6 +18,7 @@ type MockWindow = {
contentReady: boolean;
documentLoaded: boolean;
loadCallbacks: Array<() => void>;
stopLoadingCallbacks: Array<() => void>;
readyToShowCallbacks: Array<() => void>;
};
@@ -37,6 +38,7 @@ function createMockWindow(): MockWindow & {
destroy: () => void;
focus: () => void;
emitDidFinishLoad: () => void;
emitDidStopLoading: () => void;
emitReadyToShow: () => void;
once: (event: 'ready-to-show', cb: () => void) => void;
webContents: {
@@ -45,7 +47,7 @@ function createMockWindow(): MockWindow & {
getURL: () => string;
send: (channel: string, payload?: unknown) => void;
isFocused: () => boolean;
once: (event: 'did-finish-load', cb: () => void) => void;
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => void;
focus: () => void;
};
} {
@@ -65,6 +67,7 @@ function createMockWindow(): MockWindow & {
contentReady: true,
documentLoaded: true,
loadCallbacks: [],
stopLoadingCallbacks: [],
readyToShowCallbacks: [],
};
const window = {
@@ -112,6 +115,13 @@ function createMockWindow(): MockWindow & {
callback();
}
},
emitDidStopLoading: () => {
state.loading = false;
const callbacks = state.stopLoadingCallbacks.splice(0);
for (const callback of callbacks) {
callback();
}
},
emitReadyToShow: () => {
const callbacks = state.readyToShowCallbacks.splice(0);
for (const callback of callbacks) {
@@ -133,7 +143,11 @@ function createMockWindow(): MockWindow & {
},
focused: false,
isFocused: () => state.webContentsFocused,
once: (_event: 'did-finish-load', cb: () => void) => {
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => {
if (event === 'did-stop-loading') {
state.stopLoadingCallbacks.push(cb);
return;
}
state.loadCallbacks.push(cb);
},
focus: () => {
@@ -892,6 +906,44 @@ test('sendToActiveOverlayWindow delivers on first modal load without waiting for
assert.deepEqual(window.sent, [['runtime-options:open']]);
});
test('sendToActiveOverlayWindow delivers to a modal window created for the send', () => {
// Electron keeps isLoading() true inside did-finish-load, and ready-to-show can fire
// before it; only did-stop-loading sees the settled state.
const window = createMockWindow();
window.loading = true;
window.url = '';
window.contentReady = false;
window.documentLoaded = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => null,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
assert.equal(
runtime.sendToActiveOverlayWindow(
'media-timing-review:open',
{ reviewId: 'r1' },
{
restoreOnModalClose: 'media-timing-review',
preferModalWindow: true,
},
),
true,
);
assert.deepEqual(window.sent, []);
window.contentReady = true;
window.emitReadyToShow();
window.url = 'file:///overlay/index.html?layer=modal';
window.documentLoaded = true;
window.emitDidFinishLoad();
assert.deepEqual(window.sent, []);
window.emitDidStopLoading();
assert.deepEqual(window.sent, [['media-timing-review:open', { reviewId: 'r1' }]]);
});
test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => {
const window = createMockWindow();
window.contentReady = false;
+5
View File
@@ -206,6 +206,7 @@ export function createOverlayModalRuntimeService(
if (!modalWindowPrimeListenersRegistered.has(modalWindow)) {
modalWindowPrimeListenersRegistered.add(modalWindow);
modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow));
modalWindow.webContents.once('did-stop-loading', () => markModalWindowPrimed(modalWindow));
modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow));
}
return true;
@@ -281,7 +282,11 @@ export function createOverlayModalRuntimeService(
// A hidden macOS panel may not emit ready-to-show until it is presented. The
// renderer can safely receive IPC as soon as its document has finished loading.
// Electron still reports isLoading() inside did-finish-load (and ready-to-show can
// fire even earlier), so a window created for this send would drop the message
// without the did-stop-loading pass that follows once the load state settles.
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
window.webContents.once('did-stop-loading', () => deliver(() => isWindowLoadedForIpc(window)));
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
deliver(() => isWindowLoadedForIpc(window));
};
@@ -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,
},
);
+1
View File
@@ -18,6 +18,7 @@ test('sidebar clipboard bridge writes exact text without renderer focus and reje
let exposed: unknown;
runInNewContext(output.text, {
process: { argv: [] },
window: { addEventListener: () => {} },
require: (name: string) => {
assert.equal(name, 'electron');
return {
+38
View File
@@ -18,6 +18,44 @@
import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
import { resolveOverlayLayerFromArgv } from './preload-args';
import {
DICTIONARY_EXTERNAL_LINK_CHANNEL,
parseDictionaryExternalUrl,
} from './shared/dictionary-external-link';
window.addEventListener('hachidori-open-external', (event) => {
if (!(event instanceof CustomEvent)) return;
const detail: unknown = event.detail;
if (
!detail ||
typeof detail !== 'object' ||
!('requestId' in detail) ||
typeof detail.requestId !== 'string' ||
!('url' in detail)
)
return;
const requestId = detail.requestId;
void Promise.resolve()
.then(() =>
ipcRenderer.invoke(DICTIONARY_EXTERNAL_LINK_CHANNEL, parseDictionaryExternalUrl(detail.url)),
)
.then(() => {
window.dispatchEvent(
new CustomEvent('hachidori-open-external-result', { detail: { requestId, ok: true } }),
);
})
.catch((error: unknown) => {
window.dispatchEvent(
new CustomEvent('hachidori-open-external-result', {
detail: {
requestId,
ok: false,
error: error instanceof Error ? error.message : String(error),
},
}),
);
});
});
import type {
SubtitleData,
SubtitlePosition,
+2 -2
View File
@@ -368,7 +368,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] {
{ shortcut: 'Y then S', action: 'Start overlay' },
{ shortcut: 'Y then Shift + S', action: 'Stop overlay' },
{ shortcut: 'Y then T', action: 'Toggle visible overlay' },
{ shortcut: 'Y then O', action: 'Open Yomitan settings' },
{ shortcut: 'Y then O', action: 'Open dictionary settings' },
{ shortcut: 'Y then R', action: 'Restart overlay' },
{ shortcut: 'Y then C', action: 'Check overlay status' },
{ shortcut: 'Y then H/K', action: 'Open session help' },
@@ -377,7 +377,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] {
},
{
title: 'Global shortcuts',
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open Yomitan settings' }],
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open dictionary settings' }],
},
];
}
@@ -274,7 +274,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
assert.equal(state.youtubePickerModalOpen, false);
assert.deepEqual(syncCalls, ['sync', 'sync', 'restore-pointer']);
assert.deepEqual(notifications, ['youtube-track-picker']);
assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]);
assert.deepEqual(frontendCommands, []);
assert.equal(overlay.classList.contains('interactive'), false);
assert.equal(focusMainWindowCalls.length > 0, true);
assert.equal(overlayFocusCalls.length > 0, true);
@@ -1,6 +1,5 @@
import type { YoutubePickerOpenPayload } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
function createOption(value: string, label: string): HTMLOptionElement {
const option = document.createElement('option');
@@ -197,13 +196,6 @@ export function createYoutubeTrackPickerModal(
ctx.dom.youtubePickerModal.classList.add('hidden');
ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('youtube-track-picker');
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'refreshOptions',
},
}),
);
if (!options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
+2 -2
View File
@@ -230,12 +230,12 @@ async function loadYomitanAnkiDeckName(): Promise<void> {
state.yomitanAnkiDeckNameError = null;
} else {
state.yomitanAnkiDeckName = '';
state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read Yomitan Anki deck.';
state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read the dictionary Anki deck.';
}
} catch (error) {
state.yomitanAnkiDeckName = '';
state.yomitanAnkiDeckNameError =
error instanceof Error ? error.message : 'Failed to read Yomitan Anki deck.';
error instanceof Error ? error.message : 'Failed to read the dictionary Anki deck.';
} finally {
state.yomitanAnkiDeckNameLoading = false;
requestRender();
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseDictionaryExternalUrl } from './dictionary-external-link';
test('dictionary links accept web URLs and reject privileged schemes and credentials', () => {
assert.equal(
parseDictionaryExternalUrl('https://example.com/word?q=猫'),
'https://example.com/word?q=%E7%8C%AB',
);
for (const value of [
'file:///etc/passwd',
'javascript:alert(1)',
'https://user:pass@example.com',
null,
{},
]) {
assert.throws(() => parseDictionaryExternalUrl(value));
}
});
+10
View File
@@ -0,0 +1,10 @@
export const DICTIONARY_EXTERNAL_LINK_CHANNEL = 'dictionary:open-external';
export function parseDictionaryExternalUrl(value: unknown): string {
if (typeof value !== 'string') throw new Error('Expected a link URL');
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('Dictionary links must use HTTP or HTTPS without credentials');
}
return url.href;
}
+160
View File
@@ -0,0 +1,160 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildHachidoriSharingScript, parseHachidoriHostStatus } from './hachidori-sharing';
import { runInNewContext } from 'node:vm';
test('host status rejects malformed replies and distinguishes local, connected and offline', () => {
assert.deepEqual(parseHachidoriHostStatus({ ok: true, sharing: { client: { linked: false } } }), {
kind: 'local',
});
assert.throws(
() => parseHachidoriHostStatus({ ok: false, error: 'Connection refused' }),
/Connection refused/,
);
assert.throws(
() =>
parseHachidoriHostStatus({
ok: true,
sharing: {
client: { linked: true, connected: true, address: 'host', host: { dictionaryCount: -1 } },
},
}),
/dictionary count/,
);
assert.equal(
parseHachidoriHostStatus({
ok: true,
sharing: { client: { linked: true, address: 'host', connected: false } },
}).kind,
'disconnected',
);
});
test('sharing script safely links an address and checks live engine inventory', async () => {
const address = `ws://host:8771/link?quote="`;
const requests: Array<Record<string, unknown>> = [];
const script = buildHachidoriSharingScript({ type: 'hd_sharing_client_link', address });
const value: unknown = await runInNewContext(script, {
crypto,
setTimeout,
clearTimeout,
chrome: {
runtime: {
sendMessage: async (message: Record<string, unknown>) => {
requests.push(message);
if (message.type === 'hd_status') return { ok: true, ready: true, loading: false };
if (message.type === 'hd_state_read')
return { ok: true, state: { dictionaries: [{}, {}] } };
return {
ok: true,
sharing: {
client: {
linked: true,
connected: true,
address,
host: { name: 'Host', dictionaryCount: 99 },
},
},
};
},
},
},
});
assert.equal(requests[0]?.address, address);
assert.deepEqual(parseHachidoriHostStatus(value), {
kind: 'connected',
address,
name: 'Host',
dictionaryCount: 2,
});
});
test('an unresponsive host preserves its address so setup can still unlink', async () => {
const value: unknown = await runInNewContext(
buildHachidoriSharingScript({ type: 'hd_sharing_status' }),
{
crypto,
setTimeout,
clearTimeout,
chrome: {
runtime: {
sendMessage: async (message: { type: string }) => {
if (message.type === 'hd_status') throw new Error('Host stopped responding');
return {
ok: true,
sharing: {
client: {
linked: true,
connected: true,
address: 'ws://host:8771/link',
host: { dictionaryCount: 1 },
},
},
};
},
},
},
},
);
assert.deepEqual(parseHachidoriHostStatus(value), {
kind: 'disconnected',
address: 'ws://host:8771/link',
message: 'Host stopped responding',
});
});
for (const stalledCall of ['initial', 'refresh']) {
test(`sharing ${stalledCall} requests time out and clear their timers`, async () => {
const timers = new Map<number, () => void>();
let nextTimer = 0;
let sharingCalls = 0;
let stalled = false;
const result: Promise<unknown> = runInNewContext(
buildHachidoriSharingScript({ type: 'hd_sharing_status' }),
{
crypto,
setTimeout: (callback: () => void, delay: number) => {
assert.equal(delay, 5000);
timers.set(++nextTimer, callback);
return nextTimer;
},
clearTimeout: (id: number) => timers.delete(id),
chrome: {
runtime: {
sendMessage: async (message: { type: string }) => {
if (message.type === 'hd_status') return { ok: true, ready: true };
sharingCalls += 1;
if (sharingCalls === (stalledCall === 'initial' ? 1 : 2)) {
stalled = true;
return new Promise(() => {});
}
return {
ok: true,
sharing: {
client: {
linked: true,
connected: true,
address: 'host',
host: { dictionaryCount: 1 },
},
},
};
},
},
},
},
);
for (let i = 0; i < 20 && !stalled; i++) await Promise.resolve();
assert.equal(stalled, true);
assert.equal(timers.size, 1);
for (const callback of timers.values()) callback();
if (stalledCall === 'initial') {
await assert.rejects(result, /host did not respond/);
} else {
const status = parseHachidoriHostStatus(await result);
assert.equal(status.kind, 'disconnected');
if (status.kind === 'disconnected') assert.match(status.message, /host did not respond/);
}
assert.equal(timers.size, 0);
});
}
+92
View File
@@ -0,0 +1,92 @@
export type HachidoriHostStatus =
| { kind: 'local' }
| { kind: 'connected'; address: string; name: string; dictionaryCount: number }
| { kind: 'disconnected'; address: string; message: string }
| { kind: 'unavailable'; message: string };
export type HachidoriSharingRequest =
| { type: 'hd_sharing_status' }
| { type: 'hd_sharing_client_link'; address: string }
| { type: 'hd_sharing_client_unlink' };
function object(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function parseHachidoriHostStatus(reply: unknown): HachidoriHostStatus {
if (!object(reply) || reply.ok !== true || !object(reply.sharing)) {
throw new Error(
object(reply) && typeof reply.error === 'string'
? reply.error
: 'Hachidori returned an invalid sharing status.',
);
}
const client = reply.sharing.client;
if (!object(client) || typeof client.linked !== 'boolean') {
throw new Error('Hachidori returned an invalid host connection.');
}
if (!client.linked) return { kind: 'local' };
if (typeof client.address !== 'string') throw new Error('Missing Hachidori host address.');
if (client.connected !== true) {
return {
kind: 'disconnected',
address: client.address,
message:
typeof client.error === 'string'
? client.error
: 'The dictionary host is not reachable. Start it, then refresh status.',
};
}
const host = client.host;
if (
!object(host) ||
typeof host.dictionaryCount !== 'number' ||
!Number.isSafeInteger(host.dictionaryCount) ||
host.dictionaryCount < 0
) {
throw new Error('Hachidori returned an invalid dictionary count.');
}
return {
kind: 'connected',
address: client.address,
name: typeof host.name === 'string' ? host.name : 'Hachidori',
dictionaryCount: host.dictionaryCount,
};
}
export function buildHachidoriSharingScript(request: HachidoriSharingRequest): string {
return `(async () => {
const request = ${JSON.stringify(request)};
const requestWithTimeout = async fields => {
let timer;
try {
return await Promise.race([
chrome.runtime.sendMessage({ requestId: crypto.randomUUID(), ...fields }),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('The dictionary host did not respond. Check the host and refresh status.')), 5000); }),
]);
} finally { clearTimeout(timer); }
};
const send = fields => requestWithTimeout({ target: 'hachidori-sharing', ...fields });
const read = (target, type) => requestWithTimeout({ target, type });
let reply = await send(request);
if (!reply?.ok) throw new Error(reply?.error || 'Hachidori could not update the host connection.');
if (reply.sharing?.client?.linked && (reply.sharing.client.connected || request.type === 'hd_sharing_client_link')) {
try {
const engine = await read('hoshidicts-offscreen', 'hd_status');
reply = await send({type: 'hd_sharing_status'});
if (engine?.ok && engine.ready && !engine.loading && reply.sharing?.client?.connected) {
const inventory = await read('hoshidicts-worker', 'hd_state_read');
if (!inventory?.ok || !Array.isArray(inventory.state?.dictionaries)) throw new Error('Could not read the dictionary host library.');
reply.sharing.client.host.dictionaryCount = inventory.state.dictionaries.length;
} else if (reply.sharing?.client?.linked) {
reply.sharing.client.connected = false;
reply.sharing.client.error = engine?.loading ? 'The dictionary host is loading. Refresh status when it is ready.' : (engine?.error || 'The dictionary host is not ready.');
}
} catch (error) {
reply.sharing.client.connected = false;
reply.sharing.client.error = error.message;
}
}
return reply;
})()`;
}
+40
View File
@@ -9,6 +9,7 @@ import {
getDefaultConfigDir,
getDefaultConfigFilePaths,
getSetupStatePath,
isSetupCompleted,
normalizeSetupState,
readSetupState,
resolveDefaultMpvInstallPaths,
@@ -333,3 +334,42 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
),
});
});
test('setup state keeps backend identity and reads legacy completion as Yomitan', () => {
const legacy = { ...createDefaultSetupState(), status: 'completed' as const };
assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'yomitan'), true);
assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'hachidori'), false);
const hachidori = normalizeSetupState({ ...legacy, dictionaryBackend: 'hachidori' });
assert.equal(hachidori?.dictionaryBackend, 'hachidori');
assert.equal(isSetupCompleted(hachidori, 'hachidori'), true);
assert.equal(isSetupCompleted(hachidori, 'yomitan'), false);
assert.equal(normalizeSetupState({ ...legacy, dictionaryBackend: 'unknown' }), null);
});
test('setup state remembers every backend that finished setup', () => {
const state = normalizeSetupState({
...createDefaultSetupState(),
status: 'completed',
dictionaryBackend: 'hachidori',
completedDictionaryBackends: ['yomitan', 'yomitan', 'bogus'],
});
assert.deepEqual(state?.completedDictionaryBackends, ['yomitan']);
assert.equal(isSetupCompleted(state, 'yomitan'), true);
assert.equal(isSetupCompleted(state, 'hachidori'), true);
const reopened = normalizeSetupState({ ...state, status: 'incomplete' });
assert.equal(isSetupCompleted(reopened, 'hachidori'), false);
assert.equal(isSetupCompleted(reopened, 'yomitan'), true);
for (const status of ['incomplete', 'in_progress', 'cancelled']) {
const staleHistory = normalizeSetupState({
...state,
status,
completedDictionaryBackends: ['yomitan', 'hachidori'],
});
assert.equal(isSetupCompleted(staleHistory, 'hachidori'), false);
assert.equal(isSetupCompleted(staleHistory, 'yomitan'), true);
}
assert.equal(
normalizeSetupState({ ...state, completedDictionaryBackends: [] })?.completedDictionaryBackends,
undefined,
);
});
+42 -2
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import type { DictionaryBackend } from '../types/config';
import os from 'node:os';
import path from 'node:path';
import { resolveConfigDir } from '../config/path-resolution';
@@ -18,6 +19,10 @@ export interface SetupWindowsMpvShortcutPreferences {
export interface SetupState {
version: 4;
/** Backend the recorded status belongs to. Missing in legacy state files, which belong to Yomitan. */
dictionaryBackend?: DictionaryBackend;
/** Backends whose setup finished at least once, so switching back never repeats setup. */
completedDictionaryBackends?: DictionaryBackend[];
status: SetupStateStatus;
completedAt: string | null;
completionSource: SetupCompletionSource;
@@ -51,6 +56,10 @@ function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof
return platform === 'win32' ? path.win32 : path.posix;
}
function isDictionaryBackend(value: unknown): value is DictionaryBackend {
return value === 'yomitan' || value === 'hachidori';
}
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -93,6 +102,7 @@ export function normalizeSetupState(value: unknown): SetupState | null {
if (
(version !== 1 && version !== 2 && version !== 3 && version !== 4) ||
(record.dictionaryBackend !== undefined && !isDictionaryBackend(record.dictionaryBackend)) ||
(status !== 'incomplete' &&
status !== 'in_progress' &&
status !== 'completed' &&
@@ -127,8 +137,18 @@ export function normalizeSetupState(value: unknown): SetupState | null {
return null;
}
const completedDictionaryBackends = Array.isArray(record.completedDictionaryBackends)
? record.completedDictionaryBackends.filter(isDictionaryBackend)
: [];
return {
version: 4,
...(isDictionaryBackend(record.dictionaryBackend)
? { dictionaryBackend: record.dictionaryBackend }
: {}),
...(completedDictionaryBackends.length > 0
? { completedDictionaryBackends: [...new Set(completedDictionaryBackends)] }
: {}),
status,
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
completionSource,
@@ -191,8 +211,28 @@ export function normalizeSetupState(value: unknown): SetupState | null {
};
}
export function isSetupCompleted(state: SetupState | null | undefined): boolean {
return state?.status === 'completed';
export function getSetupStateDictionaryBackend(state: SetupState): DictionaryBackend {
return state.dictionaryBackend ?? 'yomitan';
}
/** Current status takes precedence; completion history applies only to other backends. */
export function hasCompletedSetupForBackend(
state: SetupState,
dictionaryBackend: DictionaryBackend,
): boolean {
if (getSetupStateDictionaryBackend(state) === dictionaryBackend) {
return state.status === 'completed';
}
return (state.completedDictionaryBackends ?? []).includes(dictionaryBackend);
}
export function isSetupCompleted(
state: SetupState | null | undefined,
dictionaryBackend?: DictionaryBackend,
): boolean {
if (!state) return false;
if (dictionaryBackend === undefined) return state.status === 'completed';
return hasCompletedSetupForBackend(state, dictionaryBackend);
}
export function getDefaultConfigDir(options?: {
+46 -23
View File
@@ -5,6 +5,14 @@ import type { BrowserWindow, Extension, Session } from 'electron';
import { ConfigService } from './config/service';
import { createLogger, setLogLevel } from './logger';
import { loadYomitanExtension } from './core/services/yomitan-extension-loader';
import {
createHachidoriExtensionRuntime,
getHachidoriSession,
} from './core/services/hachidori-extension';
import {
getPreferredYomitanAnkiServerUrl,
shouldForceOverrideYomitanAnkiServer,
} from './main/runtime/yomitan-anki-server';
import {
addYomitanNoteViaSearch,
getYomitanCurrentAnkiDeckName,
@@ -104,27 +112,38 @@ async function main(): Promise<void> {
try {
const configService = new ConfigService(userDataPath!);
const config = configService.getConfig();
const extension = await loadYomitanExtension({
userDataPath: userDataPath!,
getYomitanParserWindow: () => yomitanParserWindow,
setYomitanParserWindow: (window) => {
yomitanParserWindow = window;
},
setYomitanParserReadyPromise: (promise) => {
yomitanParserReadyPromise = promise;
},
setYomitanParserInitPromise: (promise) => {
yomitanParserInitPromise = promise;
},
setYomitanExtension: (extensionValue) => {
yomitanExt = extensionValue;
},
setYomitanSession: (sessionValue) => {
yomitanSession = sessionValue;
},
});
// Mine with the same backend the app uses so notes come from the user's
// dictionaries and Anki templates, not an empty profile.
const extension =
config.dictionaryBackend === 'hachidori'
? await createHachidoriExtensionRuntime(userDataPath!)
.ensureLoaded()
.then((loaded) => {
yomitanExt = loaded;
yomitanSession = getHachidoriSession();
return loaded;
})
: await loadYomitanExtension({
userDataPath: userDataPath!,
getYomitanParserWindow: () => yomitanParserWindow,
setYomitanParserWindow: (window) => {
yomitanParserWindow = window;
},
setYomitanParserReadyPromise: (promise) => {
yomitanParserReadyPromise = promise;
},
setYomitanParserInitPromise: (promise) => {
yomitanParserInitPromise = promise;
},
setYomitanExtension: (extensionValue) => {
yomitanExt = extensionValue;
},
setYomitanSession: (sessionValue) => {
yomitanSession = sessionValue;
},
});
if (!extension) {
throw new Error('Yomitan extension failed to load.');
throw new Error(`${config.dictionaryBackend} extension failed to load.`);
}
const yomitanDeps = {
@@ -156,17 +175,21 @@ async function main(): Promise<void> {
}
await syncYomitanDefaultAnkiServer(
config.ankiConnect?.url || 'http://127.0.0.1:8765',
getPreferredYomitanAnkiServerUrl(config.ankiConnect),
yomitanDeps,
logger,
{ forceOverride: true, deck: config.ankiConnect?.deck },
{
forceOverride: shouldForceOverrideYomitanAnkiServer(config.ankiConnect),
deck: config.ankiConnect?.deck,
ankiConfig: config.ankiConnect,
},
);
const addResult = await addYomitanNoteViaSearch(word!, yomitanDeps, logger);
const noteId = addResult.noteId;
if (typeof noteId !== 'number') {
throw new Error('Yomitan failed to create note.');
throw new Error(`${config.dictionaryBackend} failed to create note.`);
}
writeResponse(responsePath, {
+4
View File
@@ -140,7 +140,10 @@ export interface RawShortcutsConfig extends ShortcutsConfig {
openAnimetosho?: string | null;
}
export type DictionaryBackend = 'yomitan' | 'hachidori';
export interface Config {
dictionaryBackend?: DictionaryBackend;
subtitlePosition?: SubtitlePosition;
keybindings?: Keybinding[];
websocket?: WebSocketConfig;
@@ -183,6 +186,7 @@ export interface Config {
export type RawConfig = Config;
export interface ResolvedConfig {
dictionaryBackend: DictionaryBackend;
subtitlePosition: SubtitlePosition;
keybindings: Keybinding[];
websocket: Required<WebSocketConfig>;