mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(dictionary): add Hachidori backend support (#266)
This commit is contained in:
1 parent
e690ffba07
commit
cbaa4626e1
143 files changed
+5564
-305
No files matched your search
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
})()`;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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?: {
|
||||
|
||||
Reference in new issue
Block a user