fix(dictionary): complete Hachidori imports and mining integration

This commit is contained in:
2026-09-22 15:01:23 -07:00
parent 15c74ef502
commit ab5e50adac
44 changed files with 756 additions and 40 deletions
@@ -2525,6 +2525,8 @@ Aligned English subtitle
await new Promise((resolve) => setTimeout(resolve, 1));
}
const addedBeforeMediaFinished = requests.some((request) => request.action === 'addNote');
const addRequest = requests.find((request) => request.action === 'addNote');
assert.deepEqual(addRequest?.params?.note?.tags, ['SubMiner', 'SubMiner::Stats']);
mediaRelease.audio?.();
mediaRelease.image?.();
@@ -7,6 +7,7 @@ import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/ani
import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js';
import { STATS_MINING_TAG } from '../../../shared/anki-source.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
@@ -359,7 +360,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
}
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
const tags = ankiConfig.tags ?? ['SubMiner'];
const tags = [...new Set([...(ankiConfig.tags ?? ['SubMiner']), STATS_MINING_TAG])];
const addNotePromise = timeMiningPhase(
mode,
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { once } from 'node:events';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
import { importYomitanDictionaryFromZip } from './yomitan-parser-runtime';
import { createDeps } from './yomitan-scan-test-harness';
test('linked Hachidori uploads replacement bytes, retries a busy host, and never invokes local import', async () => {
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
const archive = Buffer.from('PK-test-archive');
await writeFile(zipPath, archive);
let attempts = 0;
const server = createServer(async (request, response) => {
attempts += 1;
assert.equal(request.url, '/import?name=merged.zip&replace=true');
assert.equal(request.method, 'POST');
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
assert.deepEqual(Buffer.concat(chunks), archive);
response.writeHead(attempts === 1 ? 409 : 200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify(attempts === 1 ? { error: 'busy' } : { ok: true, report: { success: true } }),
);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
assert.ok(address && typeof address === 'object');
const managementUrl = `http://127.0.0.1:${address.port}`;
let connected = true;
const deps = {
...createDeps(async (script) => {
assert.ok(script.includes('hd_sharing_status'), 'must not invoke local ZIP automation');
return {
ok: true,
sharing: {
client: {
linked: true,
connected,
address: 'ws://127.0.0.1:8771/link',
host: { dictionaryCount: 8 },
},
},
};
}),
getYomitanExt: () => ({
id: 'hachi',
name: 'Hachidori',
version: '1',
path: '',
url: '',
manifest: {},
}),
};
try {
assert.equal(
await importYomitanDictionaryFromZip(zipPath, deps, { error: assert.fail }, managementUrl),
true,
);
assert.equal(attempts, 2);
const errors: string[] = [];
const logger = { error: (...args: unknown[]) => errors.push(args.join(' ')) };
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger), false);
assert.match(errors.pop() ?? '', /externalHostManagementUrl/);
connected = false;
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger, managementUrl), false);
assert.equal(attempts, 2);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('Hachidori upload requires a successful import report, not just HTTP success', async () => {
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
await writeFile(zipPath, 'bad archive');
const server = createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({ ok: true, report: { success: false, error: 'Invalid archive' } }),
);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
assert.ok(address && typeof address === 'object');
try {
await assert.rejects(
uploadHachidoriDictionary(zipPath, `http://127.0.0.1:${address.port}`),
/Invalid archive/,
);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -0,0 +1,59 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { parseHachidoriManagementUrl } from '../../../shared/hachidori-sharing';
// Upload from the main process: extension blob URLs cannot cross the sharing link,
// and the Docker management API deliberately rejects browser cross-origin writes.
export async function uploadHachidoriDictionary(
zipPath: string,
managementUrl: string,
): Promise<void> {
const origin = parseHachidoriManagementUrl(managementUrl);
if (!origin) {
throw new Error(
'Set hachidori.externalHostManagementUrl to the linked Docker host management URL to sync character dictionaries.',
);
}
const url = new URL('/import', origin);
url.searchParams.set('name', path.basename(zipPath));
url.searchParams.set('replace', 'true');
const bytes = await readFile(zipPath);
const signal = AbortSignal.timeout(300_000);
for (;;) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: bytes,
signal,
redirect: 'error',
});
if (response.status === 409) {
await response.body?.cancel();
await delay(500, undefined, { signal });
continue;
}
const result: unknown = await response.json();
if (
response.ok &&
typeof result === 'object' &&
result !== null &&
'ok' in result &&
result.ok === true &&
'report' in result &&
typeof result.report === 'object' &&
result.report !== null &&
'success' in result.report &&
result.report.success === true
)
return;
const detail =
typeof result === 'object' &&
result !== null &&
'error' in result &&
typeof result.error === 'string'
? result.error
: JSON.stringify(result);
throw new Error(`Hachidori dictionary upload failed (${response.status}): ${detail}`);
}
}
@@ -47,7 +47,7 @@ async function createHarness(emptyLibrary = false) {
],
};
let dictionaries = [
{ id: 'terms', title: 'JMdict', enabled: true, revision: '1' },
{ id: 'terms', title: 'JMdict', displayName: 'Main dictionary', enabled: true, revision: '1' },
{ id: 'names', title: characterDictionary, enabled: true, revision: '1' },
{
id: 'frequency',
@@ -55,6 +55,7 @@ async function createHarness(emptyLibrary = false) {
enabled: true,
revision: '1',
frequencyMode: 'rank-based',
frequencyCount: 1,
},
];
let duplicate = false;
@@ -425,6 +426,44 @@ test('Hachidori stats mining returns note IDs and prevents duplicate submissions
assert.equal(harness.messages.filter((message) => message.type === 'hd_anki_submit').length, 1);
});
test('Hachidori mining requests render native dictionary aliases and frequency markers', async () => {
const harness = await createHarness();
await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail });
const request = harness.messages.find((message) => message.type === 'hd_anki_preflight')?.request;
assert.ok(request && typeof request === 'object');
assert.ok('subminerEnrich' in request && request.subminerEnrich === false);
const native: unknown = await import(
pathToFileURL(path.join(extensionPath, 'anki-values.js')).href
);
assert.ok(native && typeof native === 'object' && 'buildAnkiFields' in native);
assert.equal(typeof native.buildAnkiFields, 'function');
if (typeof native.buildAnkiFields !== 'function') assert.fail('Native renderer is unavailable');
const fields: unknown = await native.buildAnkiFields(
request,
{
Dictionary: { value: '{dictionary-alias}' },
Frequency: { value: '{single-frequency-frequency}' },
Rank: { value: '{frequency-harmonic-rank}' },
},
{},
);
assert.deepEqual(fields, {
Dictionary: 'Main dictionary',
Frequency: '<ul style="text-align: left;"><li>Frequency: </li></ul>',
Rank: '42',
});
assert.ok('dictionaryIds' in request);
assert.deepEqual(request.dictionaryIds, {
JMdict: 'terms',
[characterDictionary]: 'names',
Frequency: 'frequency',
});
assert.deepEqual(
harness.messages.find((message) => message.type === 'hd_anki_submit')?.request,
request,
);
});
test('Hachidori settings automation imports ZIP bytes and removes the matching dictionary ID', async () => {
const harness = await createHarness();
await harness.run(
@@ -236,11 +236,22 @@ export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
const lookup = await engine('hd_lookup', { text: word, maxResults: 1 });
const result = lookup.results[0];
if (!result) return { noteId: null, duplicateNoteIds: [] };
// Match the dictionary context supplied by Hachidori's popup to its
// native glossary, alias, and frequency template renderers.
const { dictionaries } = await readState();
const frequencyModes = new Map(dictionaries.map(entry => [entry.title, entry.frequencyMode]));
const term = { ...result.term, frequencies: result.term.frequencies.map(group =>
({ ...group, frequencyMode: frequencyModes.get(group.dictionary) })) };
const status = await send('hd_anki_status', {}, 'hachidori-anki');
const request = {
...result, generation: lookup.generation, sentence: word, searchQuery: word,
...result, term, generation: lookup.generation, sentence: word, searchQuery: word,
matchOffset: 0, documentTitle: 'SubMiner', popupSelectionText: '',
configKey: status.configKey,
configKey: status.configKey, subminerEnrich: false,
dictionaryAliases: Object.fromEntries(dictionaries.filter(entry => entry.displayName)
.map(entry => [entry.title, entry.displayName])),
dictionaryIds: Object.fromEntries(dictionaries.map(entry => [entry.title, entry.id])),
frequencyDictionaries: dictionaries.filter(entry => entry.enabled !== false && entry.frequencyCount > 0)
.map(entry => entry.title),
captureUnavailable: ['screenshot', 'animation', 'audio'],
};
const preflight = await send('hd_anki_preflight', { request }, 'hachidori-anki');
@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import * as vm from 'node:vm';
import {
countTermsFindLookups,
createDeps,
@@ -23,6 +24,43 @@ import {
upsertYomitanDictionarySettings,
} from './yomitan-parser-runtime';
test('Yomitan restores direct Anki after disabling its managed proxy without a page helper', async () => {
const options = { profiles: [{ options: { anki: { server: 'http://127.0.0.1:8765' } } }] };
let managedUrl: string | null = null;
const context = vm.createContext({
chrome: {
storage: {
local: {
get: async () => ({ subminerAnkiProxyUrl: managedUrl }),
set: async (value: { subminerAnkiProxyUrl: string | null }) => {
managedUrl = value.subminerAnkiProxyUrl;
},
},
},
runtime: {
sendMessage: (
message: { action: string },
callback: (response: { result: unknown }) => void,
) => callback({ result: message.action === 'optionsGetFull' ? options : null }),
},
},
});
const deps = createDeps(async (script) =>
structuredClone(await vm.runInContext(script, context)),
);
const logger = { error: assert.fail };
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, logger, {
forceOverride: true,
}),
true,
);
assert.equal(managedUrl, 'http://127.0.0.1:8766');
assert.equal(await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger), true);
assert.equal(options.profiles[0]?.options.anki.server, 'http://127.0.0.1:8765');
assert.equal(managedUrl, null);
});
test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => {
let scriptValue = '';
const deps = createDeps(async (script) => {
@@ -1,6 +1,7 @@
import type { BrowserWindow, Extension, Session } from 'electron';
import type { AnkiConnectConfig } from '../../../types';
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
import {
buildHachidoriSharingScript,
parseHachidoriHostStatus,
@@ -681,6 +682,18 @@ async function ensureYomitanParserWindow(
}
if (isHachidoriExtension(yomitanExt)) {
await parserWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
} else {
// did-finish-load precedes the search page's asynchronous backend initialization.
await parserWindow.webContents.executeJavaScript(
`(async () => {
const deadline = Date.now() + 10000;
while (typeof window.__subminerAddNote !== 'function') {
if (Date.now() >= deadline) throw new Error('Yomitan search page initialization timed out');
await new Promise(resolve => setTimeout(resolve, 50));
}
})()`,
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
@@ -1428,10 +1441,8 @@ export async function syncYomitanDefaultAnkiServer(
server: targetServer, deck: targetDeck, forceOverride, hints: hachidoriHints,
});
}
let previousManagedProxy = null;
if (typeof globalThis.__subminerSetAnkiProxyUrl === 'function') {
previousManagedProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? targetServer : null);
}
const { subminerAnkiProxyUrl: previousManagedProxy } = await chrome.storage.local.get('subminerAnkiProxyUrl');
await chrome.storage.local.set({ subminerAnkiProxyUrl: forceOverride ? targetServer : null });
const optionsFull = await invoke("optionsGetFull", undefined);
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
if (profiles.length === 0) {
@@ -1783,6 +1794,7 @@ export async function importYomitanDictionaryFromZip(
zipPath: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
hachidoriManagementUrl = '',
): Promise<boolean> {
const normalizedZipPath = zipPath.trim();
if (!normalizedZipPath || !fs.existsSync(normalizedZipPath)) {
@@ -1790,6 +1802,30 @@ export async function importYomitanDictionaryFromZip(
return false;
}
const extension = deps.getYomitanExt();
if (extension && isHachidoriExtension(extension)) {
try {
const host = await requestHachidoriSharing({ type: 'hd_sharing_status' }, deps, logger);
if (host.kind === 'disconnected' || host.kind === 'unavailable')
throw new Error(host.message);
if (host.kind === 'connected') {
await uploadHachidoriDictionary(normalizedZipPath, hachidoriManagementUrl);
const window = deps.getYomitanParserWindow();
if (window) clearYomitanParserCachesForWindow(window);
logger.info?.(
`Uploaded character dictionary to Hachidori host: ${path.basename(normalizedZipPath)}`,
);
return true;
}
} catch (error) {
logger.error(
'Hachidori character dictionary import failed:',
error instanceof Error ? error.message : String(error),
);
return false;
}
}
const supportsUrlImport = await invokeYomitanSettingsAutomation<boolean>(
`
(() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")();
@@ -30,8 +30,17 @@ export function createDeps(
}
function createYomitanScriptSandbox(handler: (action: string, params: unknown) => unknown) {
const storage: Record<string, unknown> = {};
return {
chrome: {
storage: {
local: {
get: async () => ({ ...storage }),
set: async (value: Record<string, unknown>) => {
Object.assign(storage, value);
},
},
},
runtime: {
lastError: null,
sendMessage: (
@@ -188,6 +188,11 @@ export async function loadYomitanExtension(
deps.setYomitanSession(targetSession);
try {
if (!externalProfilePath) {
// Electron may retain old extension scripts after an update, across app restarts.
// Keep dictionaries/settings while ensuring the bundled worker loads current code.
await targetSession.clearStorageData({ storages: ['serviceworkers'] });
}
const extensions = targetSession.extensions;
const extension = await withSuppressedYomitanExtensionWarnings(() =>
extensions