mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-24 05:16:19 -07:00
fix(dictionary): complete Hachidori imports and mining integration
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user