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
+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(() => {