diff --git a/changes/hachidori-backend.md b/changes/hachidori-backend.md index 92cb3391..6fcee546 100644 --- a/changes/hachidori-backend.md +++ b/changes/hachidori-backend.md @@ -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. Linked Docker hosts receive character dictionary uploads through `hachidori.externalHostManagementUrl`, retry busy imports, and replace the previous dictionary only after a successful import. +- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend. Linked Docker hosts receive character dictionary uploads at the linked machine's management port, with `hachidori.externalHostManagementUrl` as an optional override, retry busy imports, and replace the previous dictionary only after a successful import. Browser and app 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. diff --git a/config.example.jsonc b/config.example.jsonc index 23f218ea..1e131867 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -15,12 +15,12 @@ // ========================================== // Hachidori External Dictionary Imports - // Configure the linked Docker host management URL, for example http://127.0.0.1:8780. + // 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": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads. - }, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780. + "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 diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 2295c9ed..985226e8 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -73,7 +73,7 @@ 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` specifies the linked Docker host's HTTP(S) management origin for automatic character dictionary uploads and replacement. Use the management port, not the sharing or dictionary API port. See [Hachidori setup](./usage.md#hachidori-setup) for an example and [the generated configuration example](/config.example.jsonc) for the default. +`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 diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 23f218ea..1e131867 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -15,12 +15,12 @@ // ========================================== // Hachidori External Dictionary Imports - // Configure the linked Docker host management URL, for example http://127.0.0.1:8780. + // 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": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads. - }, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780. + "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 diff --git a/docs-site/usage.md b/docs-site/usage.md index df41dd5c..30abd881 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -49,7 +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. -To sync [character dictionaries](/character-dictionary) to a Docker host, set `hachidori.externalHostManagementUrl` to the same host's management origin, for example `"http://127.0.0.1:8780"`. This is not the WebSocket sharing address. SubMiner uploads the ZIP and replaces its previous dictionary once the import succeeds, retrying while the host is busy. Keep the URL pointed at the linked host. Leaving it empty turns off uploads and reports a config error when sync runs. Browser and app hosts have no management API, so automatic upload does not work with them. Local Hachidori does not need this setting. +When linked to a Docker host, SubMiner uploads [character dictionaries](/character-dictionary) to the linked machine's management port (8780) and replaces the previous dictionary once the import succeeds, retrying while the host is busy. 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 and app hosts cannot receive uploads over the link, so sync reports where the merged ZIP is and you import it from that host's Hachidori settings. Local Hachidori does not need this setting. ## Picking files diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 6465357d..69b1858e 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -86,7 +86,7 @@ export function buildCoreConfigOptionRegistry( kind: 'string', defaultValue: defaultConfig.hachidori.externalHostManagementUrl, description: - 'Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.', + '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', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index e3ab29b6..ce42b7d1 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -12,7 +12,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ { title: 'Hachidori External Dictionary Imports', description: [ - 'Configure the linked Docker host management URL, for example http://127.0.0.1:8780.', + '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', diff --git a/src/core/services/tokenizer/hachidori-dictionary-import.test.ts b/src/core/services/tokenizer/hachidori-dictionary-import.test.ts index 3f932051..03875567 100644 --- a/src/core/services/tokenizer/hachidori-dictionary-import.test.ts +++ b/src/core/services/tokenizer/hachidori-dictionary-import.test.ts @@ -5,7 +5,10 @@ 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 { + resolveHachidoriManagementUrl, + uploadHachidoriDictionary, +} from './hachidori-dictionary-import'; import { importYomitanDictionaryFromZip } from './yomitan-parser-runtime'; import { createDeps } from './yomitan-scan-test-harness'; @@ -42,7 +45,7 @@ test('linked Hachidori uploads replacement bytes, retries a busy host, and never linked: true, connected, address: 'ws://127.0.0.1:8771/link', - host: { dictionaryCount: 8 }, + host: { name: 'Hachidori Docker host', dictionaryCount: 8 }, }, }, }; @@ -62,10 +65,7 @@ test('linked Hachidori uploads replacement bytes, retries a busy host, and never 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/); + const logger = { error: () => {} }; connected = false; assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger, managementUrl), false); assert.equal(attempts, 2); @@ -98,3 +98,61 @@ test('Hachidori upload requires a successful import report, not just HTTP succes await new Promise((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); + assert.equal( + resolveHachidoriManagementUrl(docker, '', 'merged.zip', warn), + 'http://pve-main:8780', + ); + 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('browser and app hosts without an override explain the manual import', () => { + assert.throws( + () => + resolveHachidoriManagementUrl( + { address: 'ws://desktop:8771/link', name: 'Chrome' }, + '', + '/dicts/merged.zip', + ), + /Chrome at desktop cannot receive dictionary uploads\. 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((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}: `), + ); +}); diff --git a/src/core/services/tokenizer/hachidori-dictionary-import.ts b/src/core/services/tokenizer/hachidori-dictionary-import.ts index 11285af4..81559fac 100644 --- a/src/core/services/tokenizer/hachidori-dictionary-import.ts +++ b/src/core/services/tokenizer/hachidori-dictionary-import.ts @@ -1,33 +1,70 @@ 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'; +import type { HachidoriHostStatus } from '../../../shared/hachidori-sharing'; + +// hachidori-docker names itself this in the sharing hello; browser and app hosts +// report their own name and have no management API to upload to. +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, '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. Import ${zipPath} from its Hachidori settings, or link a Hachidori Docker host.`, + ); + } + 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, - managementUrl: string, -): Promise { - const origin = parseHachidoriManagementUrl(managementUrl); - if (!origin) { - throw new Error( - 'Set hachidori.externalHostManagementUrl to the linked Docker host management URL to sync character dictionaries.', - ); - } +export async function uploadHachidoriDictionary(zipPath: string, origin: string): Promise { 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', - }); + 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 }); diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index 534ebb77..51512ded 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -1,7 +1,10 @@ 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 { + resolveHachidoriManagementUrl, + uploadHachidoriDictionary, +} from './hachidori-dictionary-import'; import { buildHachidoriSharingScript, parseHachidoriHostStatus, @@ -1815,7 +1818,13 @@ export async function importYomitanDictionaryFromZip( if (host.kind === 'disconnected' || host.kind === 'unavailable') throw new Error(host.message); if (host.kind === 'connected') { - await uploadHachidoriDictionary(normalizedZipPath, hachidoriManagementUrl); + const origin = resolveHachidoriManagementUrl( + host, + hachidoriManagementUrl, + normalizedZipPath, + logger.warn, + ); + await uploadHachidoriDictionary(normalizedZipPath, origin); const window = deps.getYomitanParserWindow(); if (window) clearYomitanParserCachesForWindow(window); logger.info?.( diff --git a/src/main.ts b/src/main.ts index b66852f5..8c0f7509 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2727,6 +2727,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt { 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, );