feat(hachidori): derive Docker management URL from the linked host

- Default character dictionary uploads to the linked host on port 8780; externalHostManagementUrl is now an optional override
- Warn when the override points at a different machine than the link
- Browser/app hosts report where to import the merged ZIP manually
- Name the management origin when the upload request cannot connect
- Update config descriptions, generated examples, docs, and changelog fragment
This commit is contained in:
2026-09-29 20:22:07 -07:00
parent 4ab5e37bc5
commit b52692d826
11 changed files with 142 additions and 37 deletions
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
@@ -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<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);
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<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}: `),
);
});
@@ -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<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. 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<void> {
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<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 (;;) {
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 });
@@ -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?.(
+1
View File
@@ -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,
);