mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -07:00
feat(hachidori): send character dictionaries over the link to Hachidori hosts
Hachidori 0.2.3 accepts dictionary ZIPs as chunked upload requests: an unlinked install imports them itself and a linked one forwards them to its host. The parser bridge now imports through that path with replace, retrying a busy commit with the same upload. Linked hosts without linked-import-v1 (hachidori-docker, older Hachidori) keep the management API upload.
This commit is contained in:
11 files changed
+195
-25
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. 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 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 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
-1
@@ -49,7 +49,9 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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. 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. 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.
|
||||
|
||||
- Small units, explicit boundaries
|
||||
- Composition over monoliths
|
||||
|
||||
@@ -75,6 +75,60 @@ test('linked Hachidori uploads replacement bytes, retries a busy host, and never
|
||||
}
|
||||
});
|
||||
|
||||
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).
|
||||
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'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const archiveUrl = /importDictionaryArchiveUrl\(\s*("[^"]+")/.exec(script)?.[1];
|
||||
if (archiveUrl) linkedUploads.push(await (await fetch(JSON.parse(archiveUrl))).text());
|
||||
return true;
|
||||
};
|
||||
const settingsWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => undefined,
|
||||
webContents: { executeJavaScript: runScript },
|
||||
};
|
||||
const deps = {
|
||||
...createDeps(runScript, {
|
||||
createYomitanExtensionWindow: async () => settingsWindow,
|
||||
}),
|
||||
getYomitanExt: () => ({
|
||||
id: 'hachi',
|
||||
name: 'Hachidori',
|
||||
version: '1',
|
||||
path: '',
|
||||
url: '',
|
||||
manifest: {},
|
||||
}),
|
||||
};
|
||||
// Nothing listens here: reaching the management API would fail the import.
|
||||
assert.equal(
|
||||
await importYomitanDictionaryFromZip(
|
||||
zipPath,
|
||||
deps,
|
||||
{ error: assert.fail },
|
||||
'http://127.0.0.1:9',
|
||||
),
|
||||
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');
|
||||
@@ -129,7 +183,7 @@ test('management URL comes from the linked Docker host unless overridden', () =>
|
||||
assert.match(warnings[0] ?? '', /linked to pve-main/);
|
||||
});
|
||||
|
||||
test('browser and app hosts without an override explain the manual import', () => {
|
||||
test('hosts without linked imports or an override explain the update or manual import', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveHachidoriManagementUrl(
|
||||
@@ -137,7 +191,7 @@ test('browser and app hosts without an override explain the manual import', () =
|
||||
'',
|
||||
'/dicts/merged.zip',
|
||||
),
|
||||
/Chrome at desktop cannot receive dictionary uploads\. Import \/dicts\/merged\.zip/,
|
||||
/Chrome at desktop cannot receive dictionary uploads\. Update its Hachidori to 0\.2\.3 or later, or import \/dicts\/merged\.zip/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ 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; browser and app hosts
|
||||
// report their own name and have no management API to upload to.
|
||||
// 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;
|
||||
|
||||
@@ -35,7 +36,7 @@ export function resolveHachidoriManagementUrl(
|
||||
}
|
||||
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.`,
|
||||
`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;
|
||||
|
||||
@@ -35,6 +35,29 @@ function lookupResult(matched: string, expression: string, reading: string, dict
|
||||
};
|
||||
}
|
||||
|
||||
type UploadReply = Record<string, unknown>;
|
||||
// The parts of vendor/hachidori/extension/linked-import.js the harness drives.
|
||||
type LinkedImportModule = {
|
||||
ENGINE_BUSY_CODE: string;
|
||||
createUploadHost(options: {
|
||||
store: {
|
||||
append(token: string, data: string): void;
|
||||
discard(token: string): void;
|
||||
};
|
||||
importUpload(
|
||||
token: string,
|
||||
upload: { fileName: string; replace: boolean },
|
||||
): Promise<UploadReply>;
|
||||
setTimer(): number;
|
||||
clearTimer(): void;
|
||||
}): {
|
||||
begin(message: UploadReply, owner: string): UploadReply;
|
||||
chunk(message: UploadReply, owner: string): Promise<UploadReply>;
|
||||
commit(message: UploadReply, owner: string): Promise<UploadReply>;
|
||||
abort(message: UploadReply, owner: string): UploadReply;
|
||||
};
|
||||
};
|
||||
|
||||
async function createHarness(emptyLibrary = false) {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
let dictionaryRevision = 2;
|
||||
@@ -62,11 +85,42 @@ async function createHarness(emptyLibrary = false) {
|
||||
let proxyUrl: unknown;
|
||||
let loadingStatusReplies = 0;
|
||||
let busyEngineReplies = 0;
|
||||
let busyCommits = 0;
|
||||
const uploads: Array<{ fileName: string; replace: boolean; bytes: Buffer }> = [];
|
||||
const apiModule: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'api-host.js')).href
|
||||
);
|
||||
const linkedImportModule = (await import(
|
||||
pathToFileURL(path.join(extensionPath, 'linked-import.js')).href
|
||||
)) as LinkedImportModule;
|
||||
// Hachidori's own upload host, with the engine import replaced by a recorder.
|
||||
const uploadParts = new Map<string, Buffer[]>();
|
||||
const uploadHost = linkedImportModule.createUploadHost({
|
||||
store: {
|
||||
append: (token: string, data: string) => {
|
||||
uploadParts.set(token, [...(uploadParts.get(token) ?? []), Buffer.from(data, 'base64')]);
|
||||
},
|
||||
discard: (token: string) => {
|
||||
uploadParts.delete(token);
|
||||
},
|
||||
},
|
||||
importUpload: async (
|
||||
token: string,
|
||||
{ fileName, replace }: { fileName: string; replace: boolean },
|
||||
) => {
|
||||
if (busyCommits > 0) {
|
||||
busyCommits -= 1;
|
||||
return { ok: false, error: 'busy', errorCode: linkedImportModule.ENGINE_BUSY_CODE };
|
||||
}
|
||||
uploads.push({ fileName, replace, bytes: Buffer.concat(uploadParts.get(token) ?? []) });
|
||||
return { ok: true, report: { success: true } };
|
||||
},
|
||||
setTimer: () => 0,
|
||||
clearTimer: () => {},
|
||||
});
|
||||
const context = vm.createContext({
|
||||
__testApiModule: apiModule,
|
||||
__testLinkedImportModule: linkedImportModule,
|
||||
setTimeout,
|
||||
crypto,
|
||||
URL,
|
||||
@@ -89,6 +143,23 @@ async function createHarness(emptyLibrary = false) {
|
||||
getManifest: () => ({ version: 'test' }),
|
||||
sendMessage: async (message: Record<string, unknown>) => {
|
||||
messages.push(structuredClone(message));
|
||||
if (message.target === 'hachidori-linked-import') {
|
||||
// Mirrors background.js answerUploadRequest: commit answers with the import reply.
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'hd_import_begin':
|
||||
return { ok: true, ...uploadHost.begin(message, 'local') };
|
||||
case 'hd_import_chunk':
|
||||
return { ok: true, ...(await uploadHost.chunk(message, 'local')) };
|
||||
case 'hd_import_abort':
|
||||
return { ok: true, ...uploadHost.abort(message, 'local') };
|
||||
default:
|
||||
return await uploadHost.commit(message, 'local');
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
if (message.type === 'hd_status') {
|
||||
const loading = loadingStatusReplies > 0;
|
||||
if (loading) loadingStatusReplies -= 1;
|
||||
@@ -151,8 +222,6 @@ async function createHarness(emptyLibrary = false) {
|
||||
};
|
||||
case 'hd_anki_submit':
|
||||
return { ok: true, state: 'added', noteId: 19 };
|
||||
case 'hd_import':
|
||||
return { ok: true, report: { success: true } };
|
||||
case 'hd_remove':
|
||||
return { ok: true };
|
||||
default:
|
||||
@@ -167,7 +236,9 @@ async function createHarness(emptyLibrary = false) {
|
||||
const script = HACHIDORI_PARSER_BRIDGE_SCRIPT.replace(
|
||||
"await import('./api-host.js')",
|
||||
'__testApiModule',
|
||||
).replace("await import('./reader-options.js')", 'Promise.resolve()');
|
||||
)
|
||||
.replace("await import('./linked-import.js')", '__testLinkedImportModule')
|
||||
.replace("await import('./reader-options.js')", 'Promise.resolve()');
|
||||
await vm.runInContext(script, context);
|
||||
const run = async (code: string): Promise<unknown> =>
|
||||
structuredClone(await vm.runInContext(code, context));
|
||||
@@ -209,6 +280,10 @@ async function createHarness(emptyLibrary = false) {
|
||||
setBusyEngineReplies: (count: number) => {
|
||||
busyEngineReplies = count;
|
||||
},
|
||||
setBusyCommits: (count: number) => {
|
||||
busyCommits = count;
|
||||
},
|
||||
uploads: () => uploads,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -464,17 +539,19 @@ test('Hachidori mining requests render native dictionary aliases and frequency m
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori settings automation imports ZIP bytes and removes the matching dictionary ID', async () => {
|
||||
test('Hachidori settings automation uploads ZIP bytes as a replacement and removes the matching dictionary ID', async () => {
|
||||
const harness = await createHarness();
|
||||
// The engine is mid-change for the first commit; the same upload must still land once.
|
||||
harness.setBusyCommits(1);
|
||||
await harness.run(
|
||||
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEs=', 'characters.zip')",
|
||||
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEstdGVzdA==', 'characters.zip')",
|
||||
);
|
||||
assert.deepEqual(harness.uploads(), [
|
||||
{ fileName: 'characters.zip', replace: true, bytes: Buffer.from('PK-test') },
|
||||
]);
|
||||
await harness.run(
|
||||
`__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`,
|
||||
);
|
||||
const imported = harness.messages.find((message) => message.type === 'hd_import');
|
||||
assert.equal(imported?.fileName, 'characters.zip');
|
||||
assert.match(String(imported?.blobUrl), /^blob:/);
|
||||
const removed = harness.messages.find((message) => message.type === 'hd_remove');
|
||||
assert.equal(removed?.id, 'names');
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
|
||||
(async () => {
|
||||
if (globalThis.__subminerDictionarySendMessage) return;
|
||||
const { createApiHost } = await import('./api-host.js');
|
||||
const { uploadDictionary } = await import('./linked-import.js');
|
||||
await import('./reader-options.js');
|
||||
const send = async (type, fields = {}, target = 'hoshidicts-offscreen') => {
|
||||
const reply = await chrome.runtime.sendMessage({
|
||||
@@ -207,13 +208,26 @@ export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
|
||||
globalThis.__subminerDictionarySendMessage = ({ action, params }, callback) => {
|
||||
void invoke(action, params).then(result => callback({ result }), error => callback({ error: { message: error.message } }));
|
||||
};
|
||||
// Archives go through Hachidori's upload requests: an unlinked install imports
|
||||
// the ZIP itself and a linked one forwards it to its host. A busy engine keeps
|
||||
// the upload open, so the same commit is sent again.
|
||||
const sendUpload = async (type, fields) => {
|
||||
const deadline = Date.now() + 300000;
|
||||
for (;;) {
|
||||
const reply = await chrome.runtime.sendMessage({
|
||||
target: 'hachidori-linked-import', type, requestId: crypto.randomUUID(), ...fields,
|
||||
});
|
||||
if (type !== 'hd_import_commit' || reply?.errorCode !== 'engine-mutating' || Date.now() >= deadline) {
|
||||
return reply;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
};
|
||||
async function importArchive(blob, fileName) {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const reply = await engine('hd_import', { blobUrl, fileName });
|
||||
if (reply.report?.success !== true) throw new Error(reply.report?.error || 'Hachidori dictionary import failed');
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
await waitForEngineReady();
|
||||
const reply = await uploadDictionary({ blob, fileName, replace: true, send: sendUpload });
|
||||
if (reply?.report?.success !== true) {
|
||||
throw new Error(reply?.report?.error || reply?.error || 'Hachidori dictionary import failed');
|
||||
}
|
||||
}
|
||||
globalThis.__subminerYomitanSettingsAutomation = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from './hachidori-dictionary-import';
|
||||
import {
|
||||
buildHachidoriSharingScript,
|
||||
HACHIDORI_LINKED_IMPORT_CAPABILITY,
|
||||
parseHachidoriHostStatus,
|
||||
type HachidoriSharingRequest,
|
||||
} from '../../../shared/hachidori-sharing';
|
||||
@@ -1817,7 +1818,12 @@ 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);
|
||||
if (host.kind === 'connected') {
|
||||
// Hosts with linked imports take the ZIP over the link like a local import,
|
||||
// below. Older hosts and hachidori-docker need their management API.
|
||||
if (
|
||||
host.kind === 'connected' &&
|
||||
!host.capabilities.includes(HACHIDORI_LINKED_IMPORT_CAPABILITY)
|
||||
) {
|
||||
const origin = resolveHachidoriManagementUrl(
|
||||
host,
|
||||
hachidoriManagementUrl,
|
||||
|
||||
@@ -881,6 +881,7 @@ test('Hachidori setup gates on the linked host instead of local dictionaries', a
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
name: 'Docker',
|
||||
dictionaryCount: 2,
|
||||
capabilities: [],
|
||||
};
|
||||
const service = createFirstRunSetupService({
|
||||
configDir: dir,
|
||||
@@ -905,6 +906,7 @@ test('Hachidori setup gates on the linked host instead of local dictionaries', a
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
name: 'Docker',
|
||||
dictionaryCount: 0,
|
||||
capabilities: [],
|
||||
};
|
||||
assert.equal((await service.getSetupStatus()).canFinish, false);
|
||||
// A reachable host with no dictionaries does reopen setup.
|
||||
|
||||
@@ -52,7 +52,7 @@ test('sharing script safely links an address and checks live engine inventory',
|
||||
linked: true,
|
||||
connected: true,
|
||||
address,
|
||||
host: { name: 'Host', dictionaryCount: 99 },
|
||||
host: { name: 'Host', dictionaryCount: 99, capabilities: ['linked-import-v1', 7] },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -66,6 +66,7 @@ test('sharing script safely links an address and checks live engine inventory',
|
||||
address,
|
||||
name: 'Host',
|
||||
dictionaryCount: 2,
|
||||
capabilities: ['linked-import-v1'],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
export type HachidoriHostStatus =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'connected'; address: string; name: string; dictionaryCount: number }
|
||||
| {
|
||||
kind: 'connected';
|
||||
address: string;
|
||||
name: string;
|
||||
dictionaryCount: number;
|
||||
// Advertised by the host in its sharing hello, e.g. HACHIDORI_LINKED_IMPORT_CAPABILITY.
|
||||
capabilities: readonly string[];
|
||||
}
|
||||
| { kind: 'disconnected'; address: string; message: string }
|
||||
| { kind: 'unavailable'; message: string };
|
||||
|
||||
// Hosts with this capability (Hachidori 0.2.3+) accept dictionary ZIPs over the link.
|
||||
export const HACHIDORI_LINKED_IMPORT_CAPABILITY = 'linked-import-v1';
|
||||
|
||||
export type HachidoriSharingRequest =
|
||||
| { type: 'hd_sharing_status' }
|
||||
| { type: 'hd_sharing_client_link'; address: string }
|
||||
@@ -68,6 +78,9 @@ export function parseHachidoriHostStatus(reply: unknown): HachidoriHostStatus {
|
||||
address: client.address,
|
||||
name: typeof host.name === 'string' ? host.name : 'Hachidori',
|
||||
dictionaryCount: host.dictionaryCount,
|
||||
capabilities: Array.isArray(host.capabilities)
|
||||
? host.capabilities.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user