mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
refactor(hachidori): drop the Docker management upload for character dictionaries
Every supported host now takes dictionary ZIPs over the link, so remove the port 8780 upload, its hachidori.externalHostManagementUrl override and the hachidori config section. A linked host without linked-import-v1 now reports the update or manual-import fix.
This commit is contained in:
18 files changed
+54
-393
No files matched your search
@@ -3,7 +3,7 @@ area: dictionary
|
||||
|
||||
- Added a bundled Hachidori dictionary backend alongside the default Yomitan backend. Select it with `dictionaryBackend` and restart SubMiner.
|
||||
- The tray and dictionary-settings shortcut follow the selected backend. `--hachidori` opens Hachidori settings, while `--yomitan` opens Yomitan settings unless a read-only external Yomitan profile is configured. This restriction also applies with Hachidori active, without blocking Hachidori settings.
|
||||
- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend. Character dictionaries reach a linked host over the link (Hachidori 0.2.3 or later) and Docker hosts through their management port, with `hachidori.externalHostManagementUrl` as an optional override. Both retry busy imports and replace the previous dictionary only after a successful import. Older hosts report where to import the dictionary manually.
|
||||
- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend. Character dictionaries reach a linked host over the link (Hachidori 0.2.3 or later, or a current Docker host), retry busy imports, and replace the previous dictionary only after a successful import. Older hosts report where to import the dictionary manually.
|
||||
- Hachidori auto-populates its first Anki template from SubMiner's deck, tags, and field mappings, detects an unambiguous matching note type, and preserves existing custom templates apart from the deck, which follows `ankiConnect.deck` so polling mode enriches Hachidori cards. Anki discovery retries after an unavailable connection.
|
||||
- Hachidori saves downloadable word audio before sending a note through SubMiner's Anki proxy, so freshly mined animated cards include the word-audio delay.
|
||||
- Both bundled dictionary backends reload their background code on startup so extension updates take effect while preserving installed dictionaries and settings. Failed Yomitan connection-setting updates can be retried without manually resetting the managed Anki endpoint.
|
||||
|
||||
@@ -13,15 +13,6 @@
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Hachidori External Dictionary Imports
|
||||
// Override the linked Docker host management URL only for a non-default port or a reverse proxy.
|
||||
// Used only while Hachidori is linked to an external host.
|
||||
// ==========================================
|
||||
"hachidori": {
|
||||
"externalHostManagementUrl": "" // Management URL override for character dictionary uploads to a linked Hachidori Docker host. Empty uses the linked host on the Docker default management port.
|
||||
}, // Override the linked Docker host management URL only for a non-default port or a reverse proxy.
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
@@ -73,8 +73,6 @@ Everything else needs a restart.
|
||||
|
||||
Each backend stores its own dictionaries and mining settings. `yomitan.externalProfilePath` applies only to Yomitan. See [Hachidori setup](./usage.md#hachidori-setup) before switching an existing installation.
|
||||
|
||||
`hachidori.externalHostManagementUrl` overrides the management origin that character dictionaries upload to when Hachidori is linked to a Docker host. Leave it empty to use the linked host's machine on the Docker management port. Set it only for a non-default `ADMIN_PORT` or a reverse proxy, and use the management port, not the sharing or dictionary API port. See [Hachidori setup](./usage.md#hachidori-setup).
|
||||
|
||||
### Logging
|
||||
|
||||
Log files are named by date (`app-YYYY-MM-DD.log`, `launcher-...`, `mpv-...`). Log export writes a sanitized copy and leaves the originals alone.
|
||||
|
||||
@@ -13,15 +13,6 @@
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Hachidori External Dictionary Imports
|
||||
// Override the linked Docker host management URL only for a non-default port or a reverse proxy.
|
||||
// Used only while Hachidori is linked to an external host.
|
||||
// ==========================================
|
||||
"hachidori": {
|
||||
"externalHostManagementUrl": "" // Management URL override for character dictionary uploads to a linked Hachidori Docker host. Empty uses the linked host on the Docker default management port.
|
||||
}, // Override the linked Docker host management URL only for a non-default port or a reverse proxy.
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
+1
-3
@@ -49,9 +49,7 @@ Setup checks the connection and the host's dictionaries before **Finish** unlock
|
||||
|
||||
While linked, dictionaries and dictionary settings come from the host. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio and image processing stay local. Frequency annotations use ranks returned with dictionary entries, and SubMiner asks the host for missing ones. Words with no matching definition entry may stay unranked even if a frequency dictionary lists them.
|
||||
|
||||
SubMiner sends [character dictionaries](/character-dictionary) to the linked host over the link and replaces the previous one once the import succeeds, retrying while the host is busy. This needs Hachidori 0.2.3 or later on the host. With an older host, sync reports where the merged ZIP is so you can import it from that host's Hachidori settings.
|
||||
|
||||
Docker hosts take the upload on their management port (8780) instead. If the host uses a different `ADMIN_PORT` or sits behind a reverse proxy, set `hachidori.externalHostManagementUrl` to its management origin, for example `"http://pve-main:9000"`. This is not the WebSocket sharing address. Browser hosts and local Hachidori do not need this setting.
|
||||
SubMiner sends [character dictionaries](/character-dictionary) to the linked host over the link and replaces the previous one once the import succeeds, retrying while the host is busy. The host needs Hachidori 0.2.3 or later, or a current Docker host. With an older host, sync reports where the merged ZIP is so you can import it from that host's Hachidori settings.
|
||||
|
||||
## Picking files
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ The dictionary backend is selected once at startup by `dictionaryBackend`. Yomit
|
||||
|
||||
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
|
||||
|
||||
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Dictionary imports go through Hachidori's chunked upload requests (`hachidori-linked-import`): an unlinked install imports the ZIP itself and a linked one forwards it to its host. Hosts that do not advertise `linked-import-v1` (hachidori-docker, Hachidori before 0.2.3) get the ZIP from the main process over the Docker management API instead. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen, popup auto-pause, popup key routing, controller lookup state, and the gate on imported mpv keys and mouse buttons require an unhidden popup pane in the host's shadow root. Auto-pause rechecks after each successful lookup. Hachidori's popup lives in the overlay document rather than an iframe, so wheel events over its host scroll the popup instead of forwarding to mpv. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
|
||||
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Dictionary imports go through Hachidori's chunked upload requests (`hachidori-linked-import`): an unlinked install imports the ZIP itself and a linked one forwards it to its host. Linked hosts that do not advertise `linked-import-v1` (Hachidori before 0.2.3) cannot receive uploads, and the import reports where to import the ZIP by hand. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen, popup auto-pause, popup key routing, controller lookup state, and the gate on imported mpv keys and mouse buttons require an unhidden popup pane in the host's shadow root. Auto-pause rechecks after each successful lookup. Hachidori's popup lives in the overlay document rather than an iframe, so wheel events over its host scroll the popup instead of forwarding to mpv. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
|
||||
|
||||
- Small units, explicit boundaries
|
||||
- Composition over monoliths
|
||||
|
||||
@@ -23,7 +23,6 @@ export type {
|
||||
|
||||
const {
|
||||
dictionaryBackend,
|
||||
hachidori,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
@@ -60,7 +59,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleSelection: { enabled: false },
|
||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||
dictionaryBackend,
|
||||
hachidori,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ResolvedConfig } from '../../types/config';
|
||||
export const CORE_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'dictionaryBackend'
|
||||
| 'hachidori'
|
||||
| 'subtitlePosition'
|
||||
| 'keybindings'
|
||||
| 'websocket'
|
||||
@@ -21,7 +20,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
dictionaryBackend: 'yomitan',
|
||||
hachidori: { externalHostManagementUrl: '' },
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
websocket: {
|
||||
|
||||
@@ -81,13 +81,6 @@ export function buildCoreConfigOptionRegistry(
|
||||
] as const;
|
||||
|
||||
return [
|
||||
{
|
||||
path: 'hachidori.externalHostManagementUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.hachidori.externalHostManagementUrl,
|
||||
description:
|
||||
'Management URL override for character dictionary uploads to a linked Hachidori Docker host. Empty uses the linked host on the Docker default management port.',
|
||||
},
|
||||
{
|
||||
path: 'dictionaryBackend',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -9,14 +9,6 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'dictionaryBackend',
|
||||
},
|
||||
{
|
||||
title: 'Hachidori External Dictionary Imports',
|
||||
description: [
|
||||
'Override the linked Docker host management URL only for a non-default port or a reverse proxy.',
|
||||
],
|
||||
notes: ['Used only while Hachidori is linked to an external host.'],
|
||||
key: 'hachidori',
|
||||
},
|
||||
{
|
||||
title: 'Subtitle Selection',
|
||||
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
|
||||
|
||||
@@ -2,26 +2,10 @@ import { ResolveContext } from './context';
|
||||
import { applyControllerConfig } from './controller';
|
||||
import { isNotificationType, isOverlayNotificationPosition } from '../../types/notification';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
import { parseHachidoriManagementUrl } from '../../shared/hachidori-sharing';
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
if (isObject(src.hachidori) && src.hachidori.externalHostManagementUrl !== undefined) {
|
||||
try {
|
||||
resolved.hachidori.externalHostManagementUrl = parseHachidoriManagementUrl(
|
||||
src.hachidori.externalHostManagementUrl,
|
||||
);
|
||||
} catch {
|
||||
warn(
|
||||
'hachidori.externalHostManagementUrl',
|
||||
src.hachidori.externalHostManagementUrl,
|
||||
resolved.hachidori.externalHostManagementUrl,
|
||||
'Expected an HTTP(S) origin or an empty string.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (src.dictionaryBackend === 'yomitan' || src.dictionaryBackend === 'hachidori') {
|
||||
resolved.dictionaryBackend = src.dictionaryBackend;
|
||||
} else if (src.dictionaryBackend !== undefined) {
|
||||
|
||||
@@ -28,24 +28,3 @@ test('unknown dictionary backend values warn and preserve the default', () => {
|
||||
assert.equal(warnings[0]?.path, 'dictionaryBackend');
|
||||
}
|
||||
});
|
||||
|
||||
test('Hachidori external import URL accepts HTTP origins and rejects invalid targets', () => {
|
||||
assert.equal(resolveConfig({}).resolved.hachidori.externalHostManagementUrl, '');
|
||||
const result = resolveConfig({
|
||||
hachidori: { externalHostManagementUrl: 'http://127.0.0.1:8780/' },
|
||||
});
|
||||
assert.equal(result.resolved.hachidori.externalHostManagementUrl, 'http://127.0.0.1:8780');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
for (const value of [
|
||||
'file:///tmp/dict',
|
||||
'http://host/import',
|
||||
'http://user:password@host',
|
||||
true,
|
||||
]) {
|
||||
const { context, warnings } = createResolveContext({});
|
||||
context.src.hachidori = { externalHostManagementUrl: value };
|
||||
applyCoreDomainConfig(context);
|
||||
assert.equal(context.resolved.hachidori.externalHostManagementUrl, '');
|
||||
assert.equal(warnings[0]?.path, 'hachidori.externalHostManagementUrl');
|
||||
}
|
||||
});
|
||||
@@ -1,101 +1,28 @@
|
||||
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 {
|
||||
resolveHachidoriManagementUrl,
|
||||
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: { name: 'Hachidori Docker 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 logger = { error: () => {} };
|
||||
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('a linked host that accepts imports gets the ZIP over the link, not its management API', async () => {
|
||||
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
|
||||
await writeFile(zipPath, 'PK-linked');
|
||||
const linkedUploads: string[] = [];
|
||||
// Serves both the parser window (sharing status) and the settings window (import).
|
||||
// Hachidori deps whose sharing status reports `host`, recording every archive the
|
||||
// settings automation imports (locally or forwarded over the link).
|
||||
function hachidoriDeps(host: Record<string, unknown> | null) {
|
||||
const imported: string[] = [];
|
||||
const runScript = async (script: string): Promise<unknown> => {
|
||||
if (script.includes('hd_sharing_status')) {
|
||||
return {
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: {
|
||||
linked: true,
|
||||
connected: true,
|
||||
address: 'ws://desktop:8771/link',
|
||||
host: { name: 'Helium', dictionaryCount: 8, capabilities: ['linked-import-v1'] },
|
||||
},
|
||||
client: host
|
||||
? { linked: true, connected: true, address: 'ws://desktop:8771/link', host }
|
||||
: { linked: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
const archiveUrl = /importDictionaryArchiveUrl\(\s*("[^"]+")/.exec(script)?.[1];
|
||||
if (archiveUrl) linkedUploads.push(await (await fetch(JSON.parse(archiveUrl))).text());
|
||||
if (archiveUrl) imported.push(await (await fetch(JSON.parse(archiveUrl))).text());
|
||||
return true;
|
||||
};
|
||||
const settingsWindow = {
|
||||
@@ -103,10 +30,10 @@ test('a linked host that accepts imports gets the ZIP over the link, not its man
|
||||
destroy: () => undefined,
|
||||
webContents: { executeJavaScript: runScript },
|
||||
};
|
||||
const deps = {
|
||||
...createDeps(runScript, {
|
||||
createYomitanExtensionWindow: async () => settingsWindow,
|
||||
}),
|
||||
return {
|
||||
imported,
|
||||
deps: {
|
||||
...createDeps(runScript, { createYomitanExtensionWindow: async () => settingsWindow }),
|
||||
getYomitanExt: () => ({
|
||||
id: 'hachi',
|
||||
name: 'Hachidori',
|
||||
@@ -115,98 +42,46 @@ test('a linked host that accepts imports gets the ZIP over the link, not its man
|
||||
url: '',
|
||||
manifest: {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
// Nothing listens here: reaching the management API would fail the import.
|
||||
}
|
||||
|
||||
async function writeArchive(): Promise<string> {
|
||||
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
|
||||
await writeFile(zipPath, 'PK-test');
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
for (const [name, host] of [
|
||||
['an unlinked Hachidori imports the ZIP itself', null],
|
||||
[
|
||||
'a linked host that accepts imports gets the ZIP over the link',
|
||||
{ name: 'Helium', dictionaryCount: 8, capabilities: ['linked-import-v1'] },
|
||||
],
|
||||
] as const) {
|
||||
test(name, async () => {
|
||||
const { deps, imported } = hachidoriDeps(host);
|
||||
assert.equal(
|
||||
await importYomitanDictionaryFromZip(
|
||||
zipPath,
|
||||
deps,
|
||||
{ error: assert.fail },
|
||||
'http://127.0.0.1:9',
|
||||
),
|
||||
await importYomitanDictionaryFromZip(await writeArchive(), deps, { error: assert.fail }),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(linkedUploads, ['PK-linked']);
|
||||
});
|
||||
|
||||
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' } }),
|
||||
);
|
||||
assert.deepEqual(imported, ['PK-test']);
|
||||
});
|
||||
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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('management URL comes from the linked Docker host unless overridden', () => {
|
||||
const docker = { address: 'ws://pve-main:8771/link', name: 'Hachidori Docker host' };
|
||||
const warnings: string[] = [];
|
||||
const warn = (message: string) => warnings.push(message);
|
||||
test('a linked host without linked imports fails with the update or manual-import fix', async () => {
|
||||
const { deps, imported } = hachidoriDeps({ name: 'Chrome', dictionaryCount: 8 });
|
||||
const zipPath = await writeArchive();
|
||||
const errors: string[] = [];
|
||||
assert.equal(
|
||||
resolveHachidoriManagementUrl(docker, '', 'merged.zip', warn),
|
||||
'http://pve-main:8780',
|
||||
await importYomitanDictionaryFromZip(zipPath, deps, {
|
||||
error: (...parts: unknown[]) => errors.push(parts.join(' ')),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveHachidoriManagementUrl(docker, 'http://pve-main:9000', 'merged.zip', warn),
|
||||
'http://pve-main:9000',
|
||||
);
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.equal(
|
||||
resolveHachidoriManagementUrl(
|
||||
{ address: 'ws://127.0.0.1:8771/link', name: 'Hachidori Docker host' },
|
||||
'http://localhost:8780',
|
||||
'merged.zip',
|
||||
warn,
|
||||
),
|
||||
'http://localhost:8780',
|
||||
);
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.equal(
|
||||
resolveHachidoriManagementUrl(docker, 'http://127.0.0.1:8780', 'merged.zip', warn),
|
||||
'http://127.0.0.1:8780',
|
||||
);
|
||||
assert.match(warnings[0] ?? '', /linked to pve-main/);
|
||||
});
|
||||
|
||||
test('hosts without linked imports or an override explain the update or manual import', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveHachidoriManagementUrl(
|
||||
{ address: 'ws://desktop:8771/link', name: 'Chrome' },
|
||||
'',
|
||||
'/dicts/merged.zip',
|
||||
),
|
||||
/Chrome at desktop cannot receive dictionary uploads\. Update its Hachidori to 0\.2\.3 or later, or import \/dicts\/merged\.zip/,
|
||||
);
|
||||
});
|
||||
|
||||
test('unreachable management API names the origin instead of a bare fetch failure', async () => {
|
||||
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
|
||||
await writeFile(zipPath, 'archive');
|
||||
const server = createServer();
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
await assert.rejects(
|
||||
uploadHachidoriDictionary(zipPath, origin),
|
||||
new RegExp(`Could not reach the Hachidori management API at ${origin}: `),
|
||||
assert.deepEqual(imported, []);
|
||||
assert.match(
|
||||
errors[0] ?? '',
|
||||
/Update its Hachidori to 0\.2\.3 or later, or import .*merged\.zip/,
|
||||
);
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import type { HachidoriHostStatus } from '../../../shared/hachidori-sharing';
|
||||
|
||||
// hachidori-docker names itself this in the sharing hello. Hosts that advertise
|
||||
// linked imports take uploads over the link instead; this management API covers
|
||||
// hachidori-docker and is the only route to it.
|
||||
export const HACHIDORI_DOCKER_HOST_NAME = 'Hachidori Docker host';
|
||||
export const HACHIDORI_DOCKER_MANAGEMENT_PORT = 8780;
|
||||
|
||||
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
||||
|
||||
function sameMachine(left: string, right: string): boolean {
|
||||
return left === right || (LOOPBACK_HOSTNAMES.has(left) && LOOPBACK_HOSTNAMES.has(right));
|
||||
}
|
||||
|
||||
// Picks the management origin that holds the linked host's dictionaries. The link
|
||||
// address names the machine; `override` (hachidori.externalHostManagementUrl, already
|
||||
// normalized to an origin) covers a non-default port or a reverse proxy.
|
||||
export function resolveHachidoriManagementUrl(
|
||||
host: Pick<Extract<HachidoriHostStatus, { kind: 'connected' }>, 'address' | 'name'>,
|
||||
override: string,
|
||||
zipPath: string,
|
||||
warn?: (message: string) => void,
|
||||
): string {
|
||||
const linkHostname = new URL(host.address).hostname;
|
||||
if (override) {
|
||||
const overrideHostname = new URL(override).hostname;
|
||||
if (!sameMachine(overrideHostname, linkHostname)) {
|
||||
warn?.(
|
||||
`hachidori.externalHostManagementUrl (${override}) points at ${overrideHostname}, but Hachidori is linked to ${linkHostname}; uploads may miss the linked host.`,
|
||||
);
|
||||
}
|
||||
return override;
|
||||
}
|
||||
if (host.name !== HACHIDORI_DOCKER_HOST_NAME) {
|
||||
throw new Error(
|
||||
`The linked ${host.name} at ${linkHostname} cannot receive dictionary uploads. Update its Hachidori to 0.2.3 or later, or import ${zipPath} from its Hachidori settings.`,
|
||||
);
|
||||
}
|
||||
return new URL(`http://${linkHostname}:${HACHIDORI_DOCKER_MANAGEMENT_PORT}`).origin;
|
||||
}
|
||||
|
||||
// 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, origin: string): Promise<void> {
|
||||
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 (;;) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
body: bytes,
|
||||
signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
} catch (error) {
|
||||
// undici reports every network failure as "fetch failed"; the cause says why.
|
||||
const cause = error instanceof Error && error.cause instanceof Error ? error.cause : error;
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(`Could not reach the Hachidori management API at ${origin}: ${reason}`);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { AnkiConnectConfig } from '../../../types';
|
||||
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
|
||||
import {
|
||||
resolveHachidoriManagementUrl,
|
||||
uploadHachidoriDictionary,
|
||||
} from './hachidori-dictionary-import';
|
||||
import {
|
||||
buildHachidoriSharingScript,
|
||||
HACHIDORI_LINKED_IMPORT_CAPABILITY,
|
||||
@@ -1804,7 +1800,6 @@ export async function importYomitanDictionaryFromZip(
|
||||
zipPath: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
hachidoriManagementUrl = '',
|
||||
): Promise<boolean> {
|
||||
const normalizedZipPath = zipPath.trim();
|
||||
if (!normalizedZipPath || !fs.existsSync(normalizedZipPath)) {
|
||||
@@ -1818,25 +1813,15 @@ export async function importYomitanDictionaryFromZip(
|
||||
const host = await requestHachidoriSharing({ type: 'hd_sharing_status' }, deps, logger);
|
||||
if (host.kind === 'disconnected' || host.kind === 'unavailable')
|
||||
throw new Error(host.message);
|
||||
// Hosts with linked imports take the ZIP over the link like a local import,
|
||||
// below. Older hosts and hachidori-docker need their management API.
|
||||
// A linked host takes the ZIP over the link like a local import, below,
|
||||
// but only once it advertises linked imports (Hachidori 0.2.3+).
|
||||
if (
|
||||
host.kind === 'connected' &&
|
||||
!host.capabilities.includes(HACHIDORI_LINKED_IMPORT_CAPABILITY)
|
||||
) {
|
||||
const origin = resolveHachidoriManagementUrl(
|
||||
host,
|
||||
hachidoriManagementUrl,
|
||||
normalizedZipPath,
|
||||
logger.warn,
|
||||
throw new Error(
|
||||
`The linked ${host.name} cannot receive dictionary uploads. Update its Hachidori to 0.2.3 or later, or import ${normalizedZipPath} from its Hachidori settings.`,
|
||||
);
|
||||
await uploadHachidoriDictionary(normalizedZipPath, origin);
|
||||
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(
|
||||
|
||||
+2
-7
@@ -2732,16 +2732,11 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
return false;
|
||||
}
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return await importYomitanDictionaryFromZip(
|
||||
zipPath,
|
||||
getYomitanParserRuntimeDeps(),
|
||||
{
|
||||
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
warn: (message, ...args) => logger.warn(message, ...args),
|
||||
},
|
||||
configService.getConfig().hachidori.externalHostManagementUrl,
|
||||
);
|
||||
});
|
||||
},
|
||||
dictionaryImportReplacesExisting: () =>
|
||||
getYomitanParserRuntimeDeps().getYomitanExt()?.name === 'Hachidori',
|
||||
|
||||
@@ -19,23 +19,6 @@ export type HachidoriSharingRequest =
|
||||
| { type: 'hd_sharing_client_link'; address: string }
|
||||
| { type: 'hd_sharing_client_unlink' };
|
||||
|
||||
export function parseHachidoriManagementUrl(value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error('Expected an HTTP(S) origin or an empty string.');
|
||||
if (!value.trim()) return '';
|
||||
const url = new URL(value.trim());
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.pathname !== '/' ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
throw new Error('Expected an HTTP(S) origin without credentials, a path, query, or fragment.');
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,6 @@ export type DictionaryBackend = 'yomitan' | 'hachidori';
|
||||
|
||||
export interface Config {
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
hachidori?: { externalHostManagementUrl?: string };
|
||||
subtitlePosition?: SubtitlePosition;
|
||||
keybindings?: Keybinding[];
|
||||
websocket?: WebSocketConfig;
|
||||
@@ -189,7 +188,6 @@ export type RawConfig = Config;
|
||||
|
||||
export interface ResolvedConfig {
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
hachidori: { externalHostManagementUrl: string };
|
||||
subtitlePosition: SubtitlePosition;
|
||||
keybindings: Keybinding[];
|
||||
websocket: Required<WebSocketConfig>;
|
||||
|
||||
Reference in new issue
Block a user