feat(dictionary): add Hachidori backend support (#266)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-24 21:08:18 -07:00
1 parent e690ffba07
commit cbaa4626e1
143 files changed
+5564 -305

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
// Stats cards already own their sentence and media, including while polling Anki directly.
export const STATS_MINING_TAG = 'SubMiner::Stats';
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseDictionaryExternalUrl } from './dictionary-external-link';
test('dictionary links accept web URLs and reject privileged schemes and credentials', () => {
assert.equal(
parseDictionaryExternalUrl('https://example.com/word?q=猫'),
'https://example.com/word?q=%E7%8C%AB',
);
for (const value of [
'file:///etc/passwd',
'javascript:alert(1)',
'https://user:pass@example.com',
null,
{},
]) {
assert.throws(() => parseDictionaryExternalUrl(value));
}
});
+10
View File
@@ -0,0 +1,10 @@
export const DICTIONARY_EXTERNAL_LINK_CHANNEL = 'dictionary:open-external';
export function parseDictionaryExternalUrl(value: unknown): string {
if (typeof value !== 'string') throw new Error('Expected a link URL');
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('Dictionary links must use HTTP or HTTPS without credentials');
}
return url.href;
}
+161
View File
@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildHachidoriSharingScript, parseHachidoriHostStatus } from './hachidori-sharing';
import { runInNewContext } from 'node:vm';
test('host status rejects malformed replies and distinguishes local, connected and offline', () => {
assert.deepEqual(parseHachidoriHostStatus({ ok: true, sharing: { client: { linked: false } } }), {
kind: 'local',
});
assert.throws(
() => parseHachidoriHostStatus({ ok: false, error: 'Connection refused' }),
/Connection refused/,
);
assert.throws(
() =>
parseHachidoriHostStatus({
ok: true,
sharing: {
client: { linked: true, connected: true, address: 'host', host: { dictionaryCount: -1 } },
},
}),
/dictionary count/,
);
assert.equal(
parseHachidoriHostStatus({
ok: true,
sharing: { client: { linked: true, address: 'host', connected: false } },
}).kind,
'disconnected',
);
});
test('sharing script safely links an address and checks live engine inventory', async () => {
const address = `ws://host:8771/link?quote="`;
const requests: Array<Record<string, unknown>> = [];
const script = buildHachidoriSharingScript({ type: 'hd_sharing_client_link', address });
const value: unknown = await runInNewContext(script, {
crypto,
setTimeout,
clearTimeout,
chrome: {
runtime: {
sendMessage: async (message: Record<string, unknown>) => {
requests.push(message);
if (message.type === 'hd_status') return { ok: true, ready: true, loading: false };
if (message.type === 'hd_state_read')
return { ok: true, state: { dictionaries: [{}, {}] } };
return {
ok: true,
sharing: {
client: {
linked: true,
connected: true,
address,
host: { name: 'Host', dictionaryCount: 99, capabilities: ['linked-import-v1', 7] },
},
},
};
},
},
},
});
assert.equal(requests[0]?.address, address);
assert.deepEqual(parseHachidoriHostStatus(value), {
kind: 'connected',
address,
name: 'Host',
dictionaryCount: 2,
capabilities: ['linked-import-v1'],
});
});
test('an unresponsive host preserves its address so setup can still unlink', async () => {
const value: unknown = await runInNewContext(
buildHachidoriSharingScript({ type: 'hd_sharing_status' }),
{
crypto,
setTimeout,
clearTimeout,
chrome: {
runtime: {
sendMessage: async (message: { type: string }) => {
if (message.type === 'hd_status') throw new Error('Host stopped responding');
return {
ok: true,
sharing: {
client: {
linked: true,
connected: true,
address: 'ws://host:8771/link',
host: { dictionaryCount: 1 },
},
},
};
},
},
},
},
);
assert.deepEqual(parseHachidoriHostStatus(value), {
kind: 'disconnected',
address: 'ws://host:8771/link',
message: 'Host stopped responding',
});
});
for (const stalledCall of ['initial', 'refresh']) {
test(`sharing ${stalledCall} requests time out and clear their timers`, async () => {
const timers = new Map<number, () => void>();
let nextTimer = 0;
let sharingCalls = 0;
let stalled = false;
const result: Promise<unknown> = runInNewContext(
buildHachidoriSharingScript({ type: 'hd_sharing_status' }),
{
crypto,
setTimeout: (callback: () => void, delay: number) => {
assert.equal(delay, 5000);
timers.set(++nextTimer, callback);
return nextTimer;
},
clearTimeout: (id: number) => timers.delete(id),
chrome: {
runtime: {
sendMessage: async (message: { type: string }) => {
if (message.type === 'hd_status') return { ok: true, ready: true };
sharingCalls += 1;
if (sharingCalls === (stalledCall === 'initial' ? 1 : 2)) {
stalled = true;
return new Promise(() => {});
}
return {
ok: true,
sharing: {
client: {
linked: true,
connected: true,
address: 'host',
host: { dictionaryCount: 1 },
},
},
};
},
},
},
},
);
for (let i = 0; i < 20 && !stalled; i++) await Promise.resolve();
assert.equal(stalled, true);
assert.equal(timers.size, 1);
for (const callback of timers.values()) callback();
if (stalledCall === 'initial') {
await assert.rejects(result, /host did not respond/);
} else {
const status = parseHachidoriHostStatus(await result);
assert.equal(status.kind, 'disconnected');
if (status.kind === 'disconnected') assert.match(status.message, /host did not respond/);
}
assert.equal(timers.size, 0);
});
}
+105
View File
@@ -0,0 +1,105 @@
export type HachidoriHostStatus =
| { kind: 'local' }
| {
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 }
| { type: 'hd_sharing_client_unlink' };
function object(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function parseHachidoriHostStatus(reply: unknown): HachidoriHostStatus {
if (!object(reply) || reply.ok !== true || !object(reply.sharing)) {
throw new Error(
object(reply) && typeof reply.error === 'string'
? reply.error
: 'Hachidori returned an invalid sharing status.',
);
}
const client = reply.sharing.client;
if (!object(client) || typeof client.linked !== 'boolean') {
throw new Error('Hachidori returned an invalid host connection.');
}
if (!client.linked) return { kind: 'local' };
if (typeof client.address !== 'string') throw new Error('Missing Hachidori host address.');
if (client.connected !== true) {
return {
kind: 'disconnected',
address: client.address,
message:
typeof client.error === 'string'
? client.error
: 'The dictionary host is not reachable. Start it, then refresh status.',
};
}
const host = client.host;
if (
!object(host) ||
typeof host.dictionaryCount !== 'number' ||
!Number.isSafeInteger(host.dictionaryCount) ||
host.dictionaryCount < 0
) {
throw new Error('Hachidori returned an invalid dictionary count.');
}
return {
kind: 'connected',
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')
: [],
};
}
export function buildHachidoriSharingScript(request: HachidoriSharingRequest): string {
return `(async () => {
const request = ${JSON.stringify(request)};
const requestWithTimeout = async fields => {
let timer;
try {
return await Promise.race([
chrome.runtime.sendMessage({ requestId: crypto.randomUUID(), ...fields }),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('The dictionary host did not respond. Check the host and refresh status.')), 5000); }),
]);
} finally { clearTimeout(timer); }
};
const send = fields => requestWithTimeout({ target: 'hachidori-sharing', ...fields });
const read = (target, type) => requestWithTimeout({ target, type });
let reply = await send(request);
if (!reply?.ok) throw new Error(reply?.error || 'Hachidori could not update the host connection.');
if (reply.sharing?.client?.linked && (reply.sharing.client.connected || request.type === 'hd_sharing_client_link')) {
try {
const engine = await read('hoshidicts-offscreen', 'hd_status');
reply = await send({type: 'hd_sharing_status'});
if (engine?.ok && engine.ready && !engine.loading && reply.sharing?.client?.connected) {
const inventory = await read('hoshidicts-worker', 'hd_state_read');
if (!inventory?.ok || !Array.isArray(inventory.state?.dictionaries)) throw new Error('Could not read the dictionary host library.');
reply.sharing.client.host.dictionaryCount = inventory.state.dictionaries.length;
} else if (reply.sharing?.client?.linked) {
reply.sharing.client.connected = false;
reply.sharing.client.error = engine?.loading ? 'The dictionary host is loading. Refresh status when it is ready.' : (engine?.error || 'The dictionary host is not ready.');
}
} catch (error) {
reply.sharing.client.connected = false;
reply.sharing.client.error = error.message;
}
}
return reply;
})()`;
}
+40
View File
@@ -9,6 +9,7 @@ import {
getDefaultConfigDir,
getDefaultConfigFilePaths,
getSetupStatePath,
isSetupCompleted,
normalizeSetupState,
readSetupState,
resolveDefaultMpvInstallPaths,
@@ -312,3 +313,42 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
),
});
});
test('setup state keeps backend identity and reads legacy completion as Yomitan', () => {
const legacy = { ...createDefaultSetupState(), status: 'completed' as const };
assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'yomitan'), true);
assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'hachidori'), false);
const hachidori = normalizeSetupState({ ...legacy, dictionaryBackend: 'hachidori' });
assert.equal(hachidori?.dictionaryBackend, 'hachidori');
assert.equal(isSetupCompleted(hachidori, 'hachidori'), true);
assert.equal(isSetupCompleted(hachidori, 'yomitan'), false);
assert.equal(normalizeSetupState({ ...legacy, dictionaryBackend: 'unknown' }), null);
});
test('setup state remembers every backend that finished setup', () => {
const state = normalizeSetupState({
...createDefaultSetupState(),
status: 'completed',
dictionaryBackend: 'hachidori',
completedDictionaryBackends: ['yomitan', 'yomitan', 'bogus'],
});
assert.deepEqual(state?.completedDictionaryBackends, ['yomitan']);
assert.equal(isSetupCompleted(state, 'yomitan'), true);
assert.equal(isSetupCompleted(state, 'hachidori'), true);
const reopened = normalizeSetupState({ ...state, status: 'incomplete' });
assert.equal(isSetupCompleted(reopened, 'hachidori'), false);
assert.equal(isSetupCompleted(reopened, 'yomitan'), true);
for (const status of ['incomplete', 'in_progress', 'cancelled']) {
const staleHistory = normalizeSetupState({
...state,
status,
completedDictionaryBackends: ['yomitan', 'hachidori'],
});
assert.equal(isSetupCompleted(staleHistory, 'hachidori'), false);
assert.equal(isSetupCompleted(staleHistory, 'yomitan'), true);
}
assert.equal(
normalizeSetupState({ ...state, completedDictionaryBackends: [] })?.completedDictionaryBackends,
undefined,
);
});
+42 -2
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import type { DictionaryBackend } from '../types/config';
import os from 'node:os';
import path from 'node:path';
import { resolveConfigDir } from '../config/path-resolution';
@@ -18,6 +19,10 @@ export interface SetupWindowsMpvShortcutPreferences {
export interface SetupState {
version: 4;
/** Backend the recorded status belongs to. Missing in legacy state files, which belong to Yomitan. */
dictionaryBackend?: DictionaryBackend;
/** Backends whose setup finished at least once, so switching back never repeats setup. */
completedDictionaryBackends?: DictionaryBackend[];
status: SetupStateStatus;
completedAt: string | null;
completionSource: SetupCompletionSource;
@@ -51,6 +56,10 @@ function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof
return platform === 'win32' ? path.win32 : path.posix;
}
function isDictionaryBackend(value: unknown): value is DictionaryBackend {
return value === 'yomitan' || value === 'hachidori';
}
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -93,6 +102,7 @@ export function normalizeSetupState(value: unknown): SetupState | null {
if (
(version !== 1 && version !== 2 && version !== 3 && version !== 4) ||
(record.dictionaryBackend !== undefined && !isDictionaryBackend(record.dictionaryBackend)) ||
(status !== 'incomplete' &&
status !== 'in_progress' &&
status !== 'completed' &&
@@ -127,8 +137,18 @@ export function normalizeSetupState(value: unknown): SetupState | null {
return null;
}
const completedDictionaryBackends = Array.isArray(record.completedDictionaryBackends)
? record.completedDictionaryBackends.filter(isDictionaryBackend)
: [];
return {
version: 4,
...(isDictionaryBackend(record.dictionaryBackend)
? { dictionaryBackend: record.dictionaryBackend }
: {}),
...(completedDictionaryBackends.length > 0
? { completedDictionaryBackends: [...new Set(completedDictionaryBackends)] }
: {}),
status,
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
completionSource,
@@ -191,8 +211,28 @@ export function normalizeSetupState(value: unknown): SetupState | null {
};
}
export function isSetupCompleted(state: SetupState | null | undefined): boolean {
return state?.status === 'completed';
export function getSetupStateDictionaryBackend(state: SetupState): DictionaryBackend {
return state.dictionaryBackend ?? 'yomitan';
}
/** Current status takes precedence; completion history applies only to other backends. */
export function hasCompletedSetupForBackend(
state: SetupState,
dictionaryBackend: DictionaryBackend,
): boolean {
if (getSetupStateDictionaryBackend(state) === dictionaryBackend) {
return state.status === 'completed';
}
return (state.completedDictionaryBackends ?? []).includes(dictionaryBackend);
}
export function isSetupCompleted(
state: SetupState | null | undefined,
dictionaryBackend?: DictionaryBackend,
): boolean {
if (!state) return false;
if (dictionaryBackend === undefined) return state.status === 'completed';
return hasCompletedSetupForBackend(state, dictionaryBackend);
}
export function getDefaultConfigDir(options?: {