mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
This commit is contained in:
@@ -1793,6 +1793,9 @@ export class AnkiIntegration {
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
|
||||
log.debug(
|
||||
`[media-timing] review skipped: reviewTiming=${String(this.config.media?.reviewTiming)} callback=${this.mediaTimingReviewCallback ? 'set' : 'missing'}`,
|
||||
);
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
return await this.mediaTimingReviewCallback({
|
||||
|
||||
@@ -441,6 +441,70 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy enriches confirmed Hachidori overwrites without counting a new card or forwarding metadata', async () => {
|
||||
const received: unknown[] = [];
|
||||
let upstreamError: string | null = null;
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
received.push(JSON.parse(Buffer.concat(chunks).toString()));
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ result: null, error: upstreamError }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const address = upstream.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const processed: number[] = [];
|
||||
const added: number[] = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (id) => {
|
||||
processed.push(id);
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
added.push(count);
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
try {
|
||||
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
|
||||
await proxy.waitUntilReady();
|
||||
const server: unknown = Reflect.get(proxy, 'server');
|
||||
assert.ok(server instanceof http.Server);
|
||||
const bound = server.address();
|
||||
assert.ok(bound && typeof bound === 'object');
|
||||
for (const [id, marked, error] of [
|
||||
[51, true, null],
|
||||
[52, false, null],
|
||||
[53, true, 'failed'],
|
||||
] satisfies Array<[number, boolean, string | null]>) {
|
||||
upstreamError = error;
|
||||
await fetch(`http://127.0.0.1:${bound.port}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'updateNoteFields',
|
||||
version: 6,
|
||||
params: {
|
||||
note: { id, fields: { Expression: '猫' } },
|
||||
...(marked ? { subminerEnrich: true } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
await waitForCondition(() => processed.length > 0);
|
||||
assert.deepEqual(processed, [51]);
|
||||
assert.deepEqual(added, []);
|
||||
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
await once(upstream, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy returns addNote response without waiting for background enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
let releaseProcessing: (() => void) | undefined;
|
||||
|
||||
@@ -224,6 +224,31 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action === 'updateNoteFields') {
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
!params ||
|
||||
typeof params !== 'object' ||
|
||||
!('subminerEnrich' in params) ||
|
||||
params.subminerEnrich !== true
|
||||
)
|
||||
return;
|
||||
const note = 'note' in params ? params.note : null;
|
||||
if (!note || typeof note !== 'object' || !('id' in note)) return;
|
||||
const response = this.tryParseJsonValue(responseBody);
|
||||
// AnkiConnect confirms updates with {result:null,error:null}; failures must never enrich.
|
||||
if (
|
||||
!response ||
|
||||
typeof response !== 'object' ||
|
||||
!('error' in response) ||
|
||||
response.error !== null ||
|
||||
!('result' in response) ||
|
||||
response.result !== null
|
||||
)
|
||||
return;
|
||||
this.enqueueNotes(this.collectSingleResultId(note.id), false);
|
||||
return;
|
||||
}
|
||||
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
|
||||
return;
|
||||
}
|
||||
@@ -293,7 +318,7 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action !== 'addNote') {
|
||||
if (action !== 'addNote' && action !== 'updateNoteFields') {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
@@ -301,12 +326,17 @@ export class AnkiConnectProxyServer {
|
||||
requestJson.params && typeof requestJson.params === 'object'
|
||||
? (requestJson.params as Record<string, unknown>)
|
||||
: null;
|
||||
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
|
||||
if (
|
||||
!params ||
|
||||
(!Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds') &&
|
||||
!Object.prototype.hasOwnProperty.call(params, 'subminerEnrich'))
|
||||
) {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
const nextParams = { ...params };
|
||||
delete nextParams.subminerDuplicateNoteIds;
|
||||
delete nextParams.subminerEnrich;
|
||||
return {
|
||||
...requestJson,
|
||||
params: nextParams,
|
||||
@@ -455,7 +485,7 @@ export class AnkiConnectProxyServer {
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueNotes(noteIds: number[]): void {
|
||||
private enqueueNotes(noteIds: number[], recordAdded = true): void {
|
||||
let enqueuedCount = 0;
|
||||
const acceptedIds: number[] = [];
|
||||
for (const noteId of noteIds) {
|
||||
@@ -472,7 +502,7 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
if (recordAdded) this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isStandaloneTexthookerCommand,
|
||||
parseArgs,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldRunDictionarySettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
} from './args';
|
||||
|
||||
@@ -475,3 +476,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(setup), true);
|
||||
assert.equal(shouldStartApp(setup), true);
|
||||
});
|
||||
|
||||
test('Hachidori settings starts the app without overlay or mpv prerequisites', () => {
|
||||
const args = parseArgs(['--hachidori']);
|
||||
assert.equal(args.hachidori, true);
|
||||
assert.equal(args.yomitan, false);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(args), true);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(args), false);
|
||||
assert.equal(isStandaloneTexthookerCommand(parseArgs(['--texthooker', '--hachidori'])), false);
|
||||
assert.equal(
|
||||
shouldRunDictionarySettingsOnlyStartup(parseArgs(['--hachidori', '--start'])),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(parseArgs(['--yomitan'])), true);
|
||||
});
|
||||
|
||||
+11
-1
@@ -13,6 +13,7 @@ export interface CliArgs {
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
hachidori: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
setup: boolean;
|
||||
@@ -134,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
@@ -285,6 +287,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--yomitan') args.yomitan = true;
|
||||
else if (arg === '--hachidori') args.hachidori = true;
|
||||
else if (arg === '--settings') args.settings = true;
|
||||
else if (arg === '--sync-window') args.syncWindow = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
@@ -568,6 +571,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
@@ -647,6 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.hachidori &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.setup &&
|
||||
@@ -719,6 +724,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
@@ -769,8 +775,12 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
}
|
||||
|
||||
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
return args.yomitan && !args.hachidori && shouldRunDictionarySettingsOnlyStartup(args);
|
||||
}
|
||||
|
||||
export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
||||
return (
|
||||
args.yomitan &&
|
||||
(args.yomitan || args.hachidori) &&
|
||||
!args.background &&
|
||||
!args.start &&
|
||||
!args.stop &&
|
||||
|
||||
@@ -24,6 +24,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--settings\s+Open SubMiner settings window/);
|
||||
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
|
||||
assert.match(output, /--hachidori\s+Open Hachidori settings window/);
|
||||
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
|
||||
@@ -25,6 +25,7 @@ ${B}Overlay${R}
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--yomitan Open Yomitan settings window
|
||||
--hachidori Open Hachidori settings window
|
||||
--settings Open SubMiner settings window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
@@ -22,6 +22,7 @@ export type {
|
||||
} from './definitions/shared';
|
||||
|
||||
const {
|
||||
dictionaryBackend,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
@@ -57,6 +58,7 @@ const { stats } = STATS_DEFAULT_CONFIG;
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||
dictionaryBackend,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ResolvedConfig } from '../../types/config';
|
||||
|
||||
export const CORE_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'dictionaryBackend'
|
||||
| 'subtitlePosition'
|
||||
| 'keybindings'
|
||||
| 'websocket'
|
||||
@@ -18,6 +19,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'notifications'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
dictionaryBackend: 'yomitan',
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
websocket: {
|
||||
|
||||
@@ -81,6 +81,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
] as const;
|
||||
|
||||
return [
|
||||
{
|
||||
path: 'dictionaryBackend',
|
||||
kind: 'enum',
|
||||
enumValues: ['yomitan', 'hachidori'],
|
||||
defaultValue: defaultConfig.dictionaryBackend,
|
||||
description: 'Dictionary lookup backend. Restart SubMiner after changing this setting.',
|
||||
},
|
||||
{
|
||||
path: 'logging.level',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ConfigTemplateSection } from './shared';
|
||||
|
||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Dictionary Backend',
|
||||
description: ['Select the dictionary lookup backend: yomitan or hachidori.'],
|
||||
notes: [
|
||||
'Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.',
|
||||
],
|
||||
key: 'dictionaryBackend',
|
||||
},
|
||||
{
|
||||
title: 'Japanese Subtitle Generation',
|
||||
description: [
|
||||
|
||||
@@ -6,6 +6,17 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
if (src.dictionaryBackend === 'yomitan' || src.dictionaryBackend === 'hachidori') {
|
||||
resolved.dictionaryBackend = src.dictionaryBackend;
|
||||
} else if (src.dictionaryBackend !== undefined) {
|
||||
warn(
|
||||
'dictionaryBackend',
|
||||
src.dictionaryBackend,
|
||||
resolved.dictionaryBackend,
|
||||
"Expected 'yomitan' or 'hachidori'.",
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||
if (launchAtStartup !== undefined) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
import { buildConfigSettingsRegistry } from '../settings/registry';
|
||||
import { createResolveContext } from './context';
|
||||
import { applyCoreDomainConfig } from './core-domains';
|
||||
|
||||
test('dictionary backend defaults to Yomitan and accepts Hachidori', () => {
|
||||
assert.equal(resolveConfig({}).resolved.dictionaryBackend, 'yomitan');
|
||||
const { resolved, warnings } = resolveConfig({ dictionaryBackend: 'hachidori' });
|
||||
assert.equal(resolved.dictionaryBackend, 'hachidori');
|
||||
assert.deepEqual(warnings, []);
|
||||
const field = buildConfigSettingsRegistry(resolved).find(
|
||||
(entry) => entry.configPath === 'dictionaryBackend',
|
||||
);
|
||||
assert.equal(field?.restartBehavior, 'restart');
|
||||
assert.deepEqual(field?.enumValues, ['yomitan', 'hachidori']);
|
||||
assert.equal(field?.category, 'integrations');
|
||||
});
|
||||
|
||||
test('unknown dictionary backend values warn and preserve the default', () => {
|
||||
for (const dictionaryBackend of ['unknown', '', null, true, {}]) {
|
||||
const { context, warnings } = createResolveContext({});
|
||||
context.src.dictionaryBackend = dictionaryBackend;
|
||||
applyCoreDomainConfig(context);
|
||||
assert.equal(context.resolved.dictionaryBackend, 'yomitan');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0]?.path, 'dictionaryBackend');
|
||||
}
|
||||
});
|
||||
@@ -152,6 +152,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Discord Rich Presence',
|
||||
'Jellyfin',
|
||||
'Texthooker',
|
||||
'Dictionary Lookup',
|
||||
'Yomitan',
|
||||
'Stats dashboard',
|
||||
'Startup warmups',
|
||||
@@ -234,8 +235,8 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
|
||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||
'subtitleStyle.css': 'CSS Declarations',
|
||||
@@ -276,7 +277,7 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'subtitleSidebar.css':
|
||||
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup':
|
||||
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.',
|
||||
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a dictionary popup is open.',
|
||||
'websocket.enabled':
|
||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||
'discordPresence.updateIntervalMs':
|
||||
@@ -340,6 +341,9 @@ function humanizePath(path: string): string {
|
||||
}
|
||||
|
||||
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
|
||||
if (path === 'dictionaryBackend') {
|
||||
return { category: 'integrations', section: 'Dictionary Lookup' };
|
||||
}
|
||||
if (
|
||||
path === 'subtitleStyle.autoPauseVideoOnHover' ||
|
||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
|
||||
@@ -20,6 +20,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
@@ -133,6 +134,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
togglePrimarySubtitleBar: () => {
|
||||
calls.push('togglePrimarySubtitleBar');
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openHachidoriSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
@@ -673,6 +677,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
@@ -1133,3 +1138,14 @@ test('handleCliCommand reports async refresh-known-words errors to OSD', async (
|
||||
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
|
||||
assert.ok(calls.includes('stopApp'));
|
||||
});
|
||||
|
||||
for (const source of ['initial', 'second-instance'] as const) {
|
||||
test(`Hachidori settings command opens its own settings on ${source} invocation`, () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ hachidori: true }), source, deps);
|
||||
assert.ok(calls.includes('openHachidoriSettingsDelayed:1000'));
|
||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||
assert.equal(calls.includes('initializeOverlayRuntime'), false);
|
||||
assert.equal(calls.includes('connectMpvClient'), false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface CliCommandServiceDeps {
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
openHachidoriSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
@@ -170,6 +171,7 @@ interface MiningCliRuntime {
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -275,6 +277,11 @@ export function createCliCommandDepsRuntime(
|
||||
options.ui.openYomitanSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
options.ui.openHachidoriSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
@@ -425,6 +432,8 @@ export function handleCliCommand(
|
||||
deps.logDebug('Opened first-run setup flow.');
|
||||
} else if (args.yomitan) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.hachidori) {
|
||||
deps.openHachidoriSettingsDelayed(1000);
|
||||
} else if (args.settings) {
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.syncWindow) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { resolveHachidoriExtensionPath } from './hachidori-extension';
|
||||
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
|
||||
|
||||
test('Hachidori resolves development and packaged artifacts without falling back to Yomitan', () => {
|
||||
const options = { moduleDir: '/app/dist/core/services', resourcesPath: '/resources' };
|
||||
assert.equal(
|
||||
resolveHachidoriExtensionPath({
|
||||
...options,
|
||||
exists: (p) => p === '/app/build/hachidori/manifest.json',
|
||||
}),
|
||||
'/app/build/hachidori',
|
||||
);
|
||||
assert.equal(
|
||||
resolveHachidoriExtensionPath({
|
||||
...options,
|
||||
exists: (p) => p === '/resources/hachidori/manifest.json',
|
||||
}),
|
||||
'/resources/hachidori',
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveHachidoriExtensionPath({ ...options, exists: () => false }),
|
||||
/build:hachidori/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori updates its own extension copy and preserves the Yomitan copy', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-backend-copy-'));
|
||||
try {
|
||||
const source = path.join(root, 'source');
|
||||
const profile = path.join(root, 'profile');
|
||||
fs.mkdirSync(source);
|
||||
fs.writeFileSync(path.join(source, 'manifest.json'), '{"name":"Hachidori","version":"1"}');
|
||||
fs.mkdirSync(path.join(profile, 'extensions/yomitan'), { recursive: true });
|
||||
fs.writeFileSync(path.join(profile, 'extensions/yomitan/marker'), 'preserved');
|
||||
const options = { extensionName: 'hachidori', platform: 'linux' } satisfies Parameters<
|
||||
typeof ensureExtensionCopyAsync
|
||||
>[2];
|
||||
const copy = await ensureExtensionCopyAsync(source, profile, options);
|
||||
assert.equal(copy.targetDir, path.join(profile, 'extensions/hachidori'));
|
||||
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, false);
|
||||
fs.writeFileSync(path.join(source, 'bridge.js'), 'updated');
|
||||
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(profile, 'extensions/yomitan/marker'), 'utf8'),
|
||||
'preserved',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import electron from 'electron';
|
||||
import type { Extension, Session } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
|
||||
import { HACHIDORI_SESSION_PARTITION } from './tokenizer/hachidori-parser-bridge';
|
||||
|
||||
export function getHachidoriSession(): Session {
|
||||
return electron.session.fromPartition(HACHIDORI_SESSION_PARTITION);
|
||||
}
|
||||
|
||||
export function resolveHachidoriExtensionPath(options: {
|
||||
moduleDir: string;
|
||||
resourcesPath: string;
|
||||
exists?: (candidate: string) => boolean;
|
||||
}): string {
|
||||
const candidates = [
|
||||
path.resolve(options.moduleDir, '../../../build/hachidori'),
|
||||
path.join(options.resourcesPath, 'hachidori'),
|
||||
];
|
||||
const found = candidates.find((candidate) =>
|
||||
(options.exists ?? existsSync)(path.join(candidate, 'manifest.json')),
|
||||
);
|
||||
if (!found) throw new Error('Hachidori is not bundled. Run bun run build:hachidori.');
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Separate session keeps settings-only launches from injecting into the other backend's overlay. */
|
||||
export function createHachidoriExtensionRuntime(userDataPath: string) {
|
||||
let extension: Extension | null = null;
|
||||
let loading: Promise<Extension> | null = null;
|
||||
return {
|
||||
getSession: getHachidoriSession,
|
||||
ensureLoaded(): Promise<Extension> {
|
||||
if (extension) return Promise.resolve(extension);
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
const source = resolveHachidoriExtensionPath({
|
||||
moduleDir: __dirname,
|
||||
resourcesPath: process.resourcesPath,
|
||||
});
|
||||
const copy = await ensureExtensionCopyAsync(source, userDataPath, {
|
||||
extensionName: 'hachidori',
|
||||
});
|
||||
const session = getHachidoriSession();
|
||||
// Electron can reuse an old extension worker after its files change.
|
||||
// Drop worker registrations before loading, preserving dictionaries and settings.
|
||||
await session.clearStorageData({ storages: ['serviceworkers'] });
|
||||
extension = await session.extensions.loadExtension(copy.targetDir, {
|
||||
allowFileAccess: true,
|
||||
});
|
||||
return extension;
|
||||
})().finally(() => {
|
||||
loading = null;
|
||||
});
|
||||
return loading;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
import { buildHachidoriAnkiHints, HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
|
||||
|
||||
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
|
||||
const config = {
|
||||
tags: ['SubMiner', 'Japanese'],
|
||||
fields: { word: 'Term', sentence: 'Context', wordAudio: 'Pronunciation', image: 'Image' },
|
||||
};
|
||||
const modelFields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
|
||||
|
||||
async function harness(
|
||||
anki: unknown = {},
|
||||
models: Record<string, string[]> = { Japanese: modelFields },
|
||||
) {
|
||||
const templates: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'anki-templates.js')).href
|
||||
);
|
||||
const setup: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'anki-setup.js')).href
|
||||
);
|
||||
const context = vm.createContext({
|
||||
__templates: templates,
|
||||
__setup: setup,
|
||||
__initialAnki: anki,
|
||||
__models: models,
|
||||
structuredClone,
|
||||
URL,
|
||||
});
|
||||
vm.runInContext('globalThis.window = globalThis', context);
|
||||
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
|
||||
vm.runInContext(
|
||||
`
|
||||
let options = { ...HDReaderOptions.normaliseOptions({ anki: __initialAnki }), revision: 1 };
|
||||
let writes = 0, online = true, race = false, proxy = null;
|
||||
const readOptions = async () => structuredClone(options);
|
||||
globalThis.__subminerSetAnkiProxyUrl = async value => { const old = proxy; proxy = value; return old; };
|
||||
const send = async (type, request, target) => {
|
||||
if (type !== 'hd_options_write' || target !== 'hoshidicts-worker') throw Error('Unexpected request');
|
||||
if (race) {
|
||||
race = false;
|
||||
options.anki.templates[0].deck = options.anki.deck = 'User edit';
|
||||
options.revision++;
|
||||
}
|
||||
if (request.baseRevision !== options.revision) throw Error('conflict');
|
||||
options = { ...HDReaderOptions.normaliseOptions({ ...options, ...request.options }), revision: options.revision + 1 };
|
||||
writes++;
|
||||
};
|
||||
const __gateway = { createAnkiGateway: () => ({ discover: async ({ model }) => ({
|
||||
connected: online, model, models: Object.keys(__models), decks: ['Mining'],
|
||||
fields: __models[model] || [], errors: online ? [] : ['offline'],
|
||||
}) }) };
|
||||
`,
|
||||
context,
|
||||
);
|
||||
await vm.runInContext(
|
||||
HACHIDORI_ANKI_SETTINGS_SCRIPT.replace("await import('./anki-templates.js')", '__templates')
|
||||
.replace("await import('./anki-setup.js')", '__setup')
|
||||
.replace("await import('./anki.js')", '__gateway'),
|
||||
context,
|
||||
);
|
||||
const run = async (script: string): Promise<unknown> =>
|
||||
structuredClone(await vm.runInContext(script, context));
|
||||
const sync = () =>
|
||||
run(
|
||||
`__subminerSyncAnkiSettings(${JSON.stringify({
|
||||
server: 'http://127.0.0.1:8766',
|
||||
deck: 'Mining',
|
||||
forceOverride: true,
|
||||
hints: buildHachidoriAnkiHints(config),
|
||||
})})`,
|
||||
);
|
||||
return { run, sync };
|
||||
}
|
||||
|
||||
test('fresh Hachidori settings inherit deck, tags, a unique model and configured fields', async () => {
|
||||
const h = await harness();
|
||||
assert.deepEqual(await h.sync(), { updated: true, matched: true, pending: false });
|
||||
assert.deepEqual(
|
||||
await h.run('[options.anki.url, options.anki.deck, options.anki.model, options.anki.tags]'),
|
||||
['http://127.0.0.1:8766', 'Mining', 'Japanese', config.tags],
|
||||
);
|
||||
assert.deepEqual(
|
||||
await h.run(
|
||||
'Object.fromEntries(Object.entries(options.anki.fieldTemplates).map(([key, row]) => [key, row.value]))',
|
||||
),
|
||||
{
|
||||
Term: '{expression}',
|
||||
Reading: '{reading}',
|
||||
Definition: '{definition}',
|
||||
Context: '{sentence}',
|
||||
Pronunciation: '{audio}',
|
||||
Image: '{screenshot}',
|
||||
},
|
||||
);
|
||||
await h.sync();
|
||||
assert.equal(await h.run('writes'), 1);
|
||||
});
|
||||
|
||||
test('preserves custom templates, tags, intentional blank fields and additional templates', async () => {
|
||||
const h = await harness({
|
||||
templates: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Custom',
|
||||
deck: 'Own deck',
|
||||
model: 'Japanese',
|
||||
tags: ['own'],
|
||||
fieldTemplates: {
|
||||
Term: { value: '{reading}', overwriteMode: 'overwrite' },
|
||||
Context: { value: '', overwriteMode: 'coalesce' },
|
||||
},
|
||||
},
|
||||
{ id: 'second', name: 'Second', deck: 'Other', model: 'Other', tags: [] },
|
||||
],
|
||||
});
|
||||
const before = await h.run('options.anki.templates');
|
||||
await h.sync();
|
||||
assert.deepEqual(await h.run('options.anki.templates'), before);
|
||||
});
|
||||
|
||||
test('leaves an ambiguous model unset and retries discovery after Anki reconnects', async () => {
|
||||
const h = await harness({}, { Japanese: modelFields, Second: modelFields });
|
||||
await h.run('online = false');
|
||||
assert.deepEqual(await h.sync(), { updated: true, matched: false, pending: true });
|
||||
assert.equal(await h.run('options.anki.deck'), 'Mining');
|
||||
await h.run('online = true');
|
||||
await h.sync();
|
||||
assert.equal(await h.run('options.anki.model'), '');
|
||||
await h.run('delete __models.Second');
|
||||
await h.sync();
|
||||
assert.equal(await h.run('options.anki.model'), 'Japanese');
|
||||
});
|
||||
|
||||
test('fills missing basic mappings only with fields belonging to the selected model', async () => {
|
||||
const h = await harness(
|
||||
{ model: 'Japanese', fields: { expression: 'Reading' } },
|
||||
{ Japanese: ['Term', 'Reading', 'Context'] },
|
||||
);
|
||||
await h.sync();
|
||||
assert.deepEqual(
|
||||
await h.run(
|
||||
'[options.anki.fields.expression, options.anki.fields.sentence, options.anki.fields.audio, options.anki.fieldTemplates]',
|
||||
),
|
||||
['Reading', 'Context', '', null],
|
||||
);
|
||||
});
|
||||
|
||||
test('re-reads concurrent settings edits before retrying its revisioned write', async () => {
|
||||
const h = await harness();
|
||||
await h.run('race = true');
|
||||
await h.sync();
|
||||
assert.equal(await h.run('options.anki.deck'), 'User edit');
|
||||
assert.equal(await h.run('options.anki.model'), 'Japanese');
|
||||
});
|
||||
|
||||
test('keeps sentence audio out of captured-audio settings and uses wordAudio first', () => {
|
||||
const hints = buildHachidoriAnkiHints({
|
||||
fields: { audio: 'SentenceAudio', wordAudio: 'WordAudio' },
|
||||
});
|
||||
assert.equal(hints.fields.audio, 'WordAudio');
|
||||
assert.equal('captureAudio' in hints.fields, false);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { AnkiConnectConfig } from '../../../types';
|
||||
|
||||
// Only settings with the same meaning in both apps cross this boundary.
|
||||
export function buildHachidoriAnkiHints(config: AnkiConnectConfig) {
|
||||
return {
|
||||
tags: config.tags,
|
||||
fields: {
|
||||
expression: config.fields?.word?.trim(),
|
||||
audio: (config.fields?.wordAudio || config.fields?.audio)?.trim(),
|
||||
sentence: config.fields?.sentence?.trim(),
|
||||
screenshot: config.fields?.image?.trim(),
|
||||
},
|
||||
model: config.isLapis?.enabled ? config.isLapis.sentenceCardModel?.trim() : undefined,
|
||||
family: config.isLapis?.enabled
|
||||
? 'lapis'
|
||||
: config.isKiku?.enabled
|
||||
? 'kiku'
|
||||
: config.isSenren?.enabled
|
||||
? 'senren'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Installed inside the hidden Hachidori settings page. Uses local option writes
|
||||
// even when dictionaries are remote or their host is offline.
|
||||
export const HACHIDORI_ANKI_SETTINGS_SCRIPT = String.raw`
|
||||
globalThis.__subminerSyncAnkiSettings = async ({ server, deck, forceOverride, hints }) => {
|
||||
const previousProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? server : null);
|
||||
const { applyAnkiPreset, resolveAnkiTemplates } = await import('./anki-templates.js');
|
||||
const { ankiSetupFamily } = await import('./anki-setup.js');
|
||||
const { createAnkiGateway } = await import('./anki.js');
|
||||
const gateway = createAnkiGateway({ timeoutMs: 2000 });
|
||||
const discoveries = new Map();
|
||||
const discover = async (anki, model) => {
|
||||
const key = JSON.stringify([anki.url, anki.apiKey, model]);
|
||||
if (!discoveries.has(key)) discoveries.set(key, gateway.discover({ ...anki, model }));
|
||||
return discoveries.get(key);
|
||||
};
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const options = await readOptions();
|
||||
let anki = options.anki;
|
||||
const canReplaceServer = forceOverride || !anki.url || anki.url === server
|
||||
|| anki.url === 'http://127.0.0.1:8765' || anki.url === previousProxy;
|
||||
if (!canReplaceServer) return { updated: false, matched: false, reason: 'blocked-existing-server' };
|
||||
anki = { ...anki, url: server };
|
||||
const first = anki.templates[0];
|
||||
if (!first) return { updated: false, matched: false, reason: 'no-templates' };
|
||||
let template = { ...first, fields: { ...first.fields } };
|
||||
const pristine = !first.model && first.fieldTemplates === null
|
||||
&& Object.values(first.fields).every(value => !value);
|
||||
if (deck && (!first.deck || (pristine && first.deck === 'Default'))) template.deck = deck;
|
||||
if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) {
|
||||
template.tags = [...hints.tags];
|
||||
}
|
||||
let pending = false;
|
||||
// Advanced templates include intentionally blank fields. Preserve them
|
||||
// as a unit rather than replacing them with inferred mappings.
|
||||
if (hints && first.fieldTemplates === null) {
|
||||
let discovery = await discover(anki, template.model || hints.model || '');
|
||||
if (!discovery.connected) {
|
||||
pending = true;
|
||||
} else {
|
||||
if (!template.model) {
|
||||
const candidates = hints.model
|
||||
? discovery.models.filter(model => model === hints.model)
|
||||
: discovery.models.filter(model => !hints.family || ankiSetupFamily(model) === hints.family);
|
||||
const anchors = [hints.fields.expression, hints.fields.sentence].filter(Boolean);
|
||||
const matches = [];
|
||||
// Require a configured word and sentence field for an inferred
|
||||
// model. Never choose the first of several compatible note types.
|
||||
if (anchors.length === 2) for (const model of candidates) {
|
||||
const result = await discover(anki, model);
|
||||
if (!result.connected || result.errors.length) { pending = true; break; }
|
||||
if (anchors.every(field => result.fields.includes(field))) matches.push(result);
|
||||
}
|
||||
if (!pending && matches.length === 1) {
|
||||
discovery = matches[0];
|
||||
template.model = discovery.model;
|
||||
}
|
||||
}
|
||||
if (template.model && discovery.model === template.model && discovery.fields.length) {
|
||||
const fields = discovery.fields;
|
||||
for (const [semantic, field] of Object.entries(hints.fields)) {
|
||||
if (!template.fields[semantic] && field && fields.includes(field)) template.fields[semantic] = field;
|
||||
}
|
||||
if (Object.values(first.fields).every(value => !value)) {
|
||||
const preset = applyAnkiPreset(template, fields, ankiSetupFamily(template.model) || 'automatic');
|
||||
const configured = resolveAnkiTemplates(template, fields).templates;
|
||||
const markers = new Set(Object.entries(template.fields)
|
||||
.filter(([, value]) => value).map(([key]) => '{' + key + '}'));
|
||||
for (const row of Object.values(preset.fieldTemplates)) {
|
||||
for (const marker of markers) row.value = row.value.replaceAll(marker, '');
|
||||
}
|
||||
for (const [field, row] of Object.entries(configured)) {
|
||||
if (row.value) preset.fieldTemplates[field] = row;
|
||||
}
|
||||
template = preset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hachidori retains a compatibility projection of its first template.
|
||||
// Updating both prevents its normalizer from restoring stale values.
|
||||
const templateConfig = Object.fromEntries(globalThis.HDReaderOptions.ANKI_TEMPLATE_CONFIG_KEYS
|
||||
.map(key => [key, template[key]]));
|
||||
anki = { ...anki, ...templateConfig,
|
||||
templates: [template, ...anki.templates.slice(1)] };
|
||||
const changed = JSON.stringify(anki) !== JSON.stringify(options.anki);
|
||||
try {
|
||||
if (changed) await send('hd_options_write', {
|
||||
baseRevision: options.revision, options: { anki },
|
||||
}, 'hoshidicts-worker');
|
||||
return { updated: changed, matched: !pending, pending };
|
||||
} catch (error) {
|
||||
if (attempt > 0) throw error;
|
||||
// Re-read after a concurrent settings save before filling anything.
|
||||
}
|
||||
}
|
||||
};
|
||||
`;
|
||||
@@ -0,0 +1,446 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as vm from 'node:vm';
|
||||
import { HACHIDORI_PARSER_BRIDGE_SCRIPT } from './hachidori-parser-bridge';
|
||||
import {
|
||||
requestYomitanScanTokens,
|
||||
requestYomitanParseResults,
|
||||
requestYomitanTermFrequencies,
|
||||
syncYomitanDefaultAnkiServer,
|
||||
addYomitanNoteViaSearch,
|
||||
} from './yomitan-parser-runtime';
|
||||
import { createDeps } from './yomitan-scan-test-harness';
|
||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||
|
||||
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
|
||||
const characterDictionary = 'SubMiner Character Dictionary (AniList 1)';
|
||||
|
||||
function lookupResult(matched: string, expression: string, reading: string, dictionary = 'JMdict') {
|
||||
return {
|
||||
matched,
|
||||
deinflected: expression,
|
||||
trace: [],
|
||||
term: {
|
||||
expression,
|
||||
reading,
|
||||
score: 0,
|
||||
rules: 'v1',
|
||||
glossaries: [{ dictionary, glossary: '["definition"]', termTags: '', definitionTags: '' }],
|
||||
pitches: [],
|
||||
frequencies: [{ dictionary: 'Frequency', frequencies: [{ value: 42, displayValue: '' }] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createHarness(emptyLibrary = false) {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
let dictionaryRevision = 2;
|
||||
let optionRevision = 3;
|
||||
let anki = {
|
||||
url: 'http://127.0.0.1:8765',
|
||||
templates: [
|
||||
{ id: 'default', name: 'Default', deck: 'Old', model: 'Japanese', fields: {} },
|
||||
{ id: 'second', name: 'Second', deck: 'Other', model: 'Japanese', fields: {} },
|
||||
],
|
||||
};
|
||||
let dictionaries = [
|
||||
{ id: 'terms', title: 'JMdict', enabled: true, revision: '1' },
|
||||
{ id: 'names', title: characterDictionary, enabled: true, revision: '1' },
|
||||
{
|
||||
id: 'frequency',
|
||||
title: 'Frequency',
|
||||
enabled: true,
|
||||
revision: '1',
|
||||
frequencyMode: 'rank-based',
|
||||
},
|
||||
];
|
||||
let duplicate = false;
|
||||
let proxyUrl: unknown;
|
||||
let loadingStatusReplies = 0;
|
||||
let busyEngineReplies = 0;
|
||||
const apiModule: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'api-host.js')).href
|
||||
);
|
||||
const context = vm.createContext({
|
||||
__testApiModule: apiModule,
|
||||
setTimeout,
|
||||
crypto,
|
||||
URL,
|
||||
Blob,
|
||||
Uint8Array,
|
||||
atob,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
set: async (value: Record<string, unknown>) => {
|
||||
proxyUrl = value.subminerAnkiProxyUrl;
|
||||
},
|
||||
get: async () => ({
|
||||
options: { anki, scanLength: 40, revision: optionRevision },
|
||||
subminerAnkiProxyUrl: proxyUrl,
|
||||
}),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getManifest: () => ({ version: 'test' }),
|
||||
sendMessage: async (message: Record<string, unknown>) => {
|
||||
messages.push(structuredClone(message));
|
||||
if (message.type === 'hd_status') {
|
||||
const loading = loadingStatusReplies > 0;
|
||||
if (loading) loadingStatusReplies -= 1;
|
||||
return { ok: true, ready: !loading, loading };
|
||||
}
|
||||
if (busyEngineReplies > 0 && message.target !== 'hachidori-anki') {
|
||||
busyEngineReplies -= 1;
|
||||
return { ok: false, error: 'the dictionary engine is busy mutating' };
|
||||
}
|
||||
switch (message.type) {
|
||||
case 'hd_state_read':
|
||||
return {
|
||||
ok: true,
|
||||
state: emptyLibrary ? null : { revision: dictionaryRevision, dictionaries },
|
||||
};
|
||||
case 'hd_lookup': {
|
||||
const text = String(message.text);
|
||||
const candidates = [
|
||||
lookupResult('食べた', '食べる', 'たべる'),
|
||||
lookupResult('食べる', '食べる', 'たべる'),
|
||||
lookupResult('ミナト', 'ミナト', 'みなと', characterDictionary),
|
||||
];
|
||||
return {
|
||||
ok: true,
|
||||
generation: 1,
|
||||
results: candidates.filter((result) => text.startsWith(result.matched)),
|
||||
};
|
||||
}
|
||||
case 'hd_frequencies':
|
||||
return {
|
||||
ok: true,
|
||||
frequencies: [
|
||||
{
|
||||
term: '食べる',
|
||||
reading: 'たべる',
|
||||
hasReading: true,
|
||||
dictionary: 'Frequency',
|
||||
frequency: 42,
|
||||
displayValue: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
case 'hd_options_write': {
|
||||
if (message.baseRevision !== optionRevision) return { ok: false, error: 'conflict' };
|
||||
const update = message.options;
|
||||
assert.ok(update && typeof update === 'object' && 'anki' in update);
|
||||
const value = update.anki;
|
||||
assert.ok(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'url' in value &&
|
||||
typeof value.url === 'string',
|
||||
);
|
||||
assert.ok('templates' in value && Array.isArray(value.templates));
|
||||
anki = { url: value.url, templates: value.templates };
|
||||
optionRevision += 1;
|
||||
return { ok: true };
|
||||
}
|
||||
case 'hd_apply_state': {
|
||||
assert.equal(message.baseRevision, dictionaryRevision);
|
||||
assert.ok(Array.isArray(message.dictionaries));
|
||||
dictionaries = message.dictionaries;
|
||||
dictionaryRevision += 1;
|
||||
return { ok: true };
|
||||
}
|
||||
case 'hd_anki_status':
|
||||
return { ok: true, configKey: 'configuration' };
|
||||
case 'hd_anki_preflight':
|
||||
return {
|
||||
ok: true,
|
||||
canAdd: !duplicate,
|
||||
state: duplicate ? 'duplicate' : 'addable',
|
||||
noteIds: duplicate ? [15] : [],
|
||||
};
|
||||
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:
|
||||
throw new Error('Unexpected native request: ' + String(message.type));
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
vm.runInContext('globalThis.window = globalThis', context);
|
||||
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
|
||||
const script = HACHIDORI_PARSER_BRIDGE_SCRIPT.replace(
|
||||
"await import('./api-host.js')",
|
||||
'__testApiModule',
|
||||
).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));
|
||||
const invoke = (action: string, params?: unknown) =>
|
||||
run(`new Promise((resolve, reject) => {
|
||||
__subminerDictionarySendMessage(${JSON.stringify({ action, params })}, response => {
|
||||
if (response.error) reject(new Error(response.error.message)); else resolve(response.result);
|
||||
});
|
||||
})`);
|
||||
return {
|
||||
deps: createDeps(run),
|
||||
run,
|
||||
invoke,
|
||||
messages,
|
||||
anki: () => anki,
|
||||
proxyUrl: () => proxyUrl,
|
||||
setAnkiServer: (url: string) => {
|
||||
anki = { ...anki, url };
|
||||
},
|
||||
setAnkiTemplates: (templates: typeof anki.templates) => {
|
||||
anki = { ...anki, templates };
|
||||
},
|
||||
dictionaries: () => dictionaries,
|
||||
disableDictionary: (id: string) => {
|
||||
dictionaries = dictionaries.map((entry) =>
|
||||
entry.id === id ? { ...entry, enabled: false } : entry,
|
||||
);
|
||||
dictionaryRevision += 1;
|
||||
},
|
||||
setDuplicate: () => {
|
||||
duplicate = true;
|
||||
},
|
||||
changeRevision: () => {
|
||||
optionRevision += 1;
|
||||
},
|
||||
setLoadingStatusReplies: (count: number) => {
|
||||
loadingStatusReplies = count;
|
||||
},
|
||||
setBusyEngineReplies: (count: number) => {
|
||||
busyEngineReplies = count;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Hachidori runs the shared scanner with inflected offsets, headwords, names and frequencies', async () => {
|
||||
const harness = await createHarness();
|
||||
const tokens = await requestYomitanScanTokens(
|
||||
'ミナト 食べた',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
},
|
||||
);
|
||||
assert.ok(tokens);
|
||||
assert.equal(tokens[0]?.surface, 'ミナト');
|
||||
assert.equal(tokens[0]?.isNameMatch, true);
|
||||
assert.equal(tokens[1]?.surface, '食べた');
|
||||
assert.equal(tokens[1]?.headword, '食べる');
|
||||
assert.equal(tokens[1]?.startPos, 4);
|
||||
assert.equal(tokens[1]?.endPos, 7);
|
||||
assert.equal(tokens[1]?.frequencyRank, 42);
|
||||
assert.deepEqual(tokens[1]?.wordClasses, ['v1']);
|
||||
const frequencies = await requestYomitanTermFrequencies(
|
||||
[{ term: '食べる', reading: 'たべる' }],
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
);
|
||||
assert.equal(frequencies[0]?.frequency, 42);
|
||||
assert.equal(frequencies[0]?.dictionary, 'Frequency');
|
||||
});
|
||||
|
||||
test('Hachidori syncs the Anki endpoint and every term template through revisioned writes', async () => {
|
||||
const harness = await createHarness();
|
||||
const synced = await syncYomitanDefaultAnkiServer(
|
||||
'http://127.0.0.1:8766',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{ deck: 'Mining' },
|
||||
);
|
||||
assert.equal(synced, true);
|
||||
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
|
||||
assert.deepEqual(
|
||||
harness.anki().templates.map((template) => template.deck),
|
||||
['Mining', 'Mining'],
|
||||
);
|
||||
assert.equal(harness.proxyUrl(), null);
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer(
|
||||
'http://127.0.0.1:8766',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{ forceOverride: true },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(harness.proxyUrl(), 'http://127.0.0.1:8766');
|
||||
await assert.rejects(
|
||||
harness.invoke('setAllSettings', {
|
||||
value: {
|
||||
hachidoriRevisions: { dictionaries: 99, options: 99 },
|
||||
profiles: [{ options: { dictionaries: [], anki: { server: '', cardFormats: [] } } }],
|
||||
},
|
||||
}),
|
||||
/settings changed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori applies only SubMiner changes when its settings moved on meanwhile', async () => {
|
||||
const harness = await createHarness();
|
||||
const projected = (await harness.invoke('optionsGetFull')) as {
|
||||
profiles: Array<{ options: { dictionaries: Array<{ name: string; enabled: boolean }> } }>;
|
||||
};
|
||||
const jmdict = projected.profiles[0]!.options.dictionaries.find(
|
||||
(entry) => entry.name === 'JMdict',
|
||||
);
|
||||
jmdict!.enabled = false;
|
||||
// Hachidori changed both revisions after SubMiner read them.
|
||||
harness.disableDictionary('names');
|
||||
harness.setAnkiServer('http://192.168.1.10:8765');
|
||||
harness.changeRevision();
|
||||
assert.equal(await harness.invoke('setAllSettings', { value: projected }), true);
|
||||
assert.deepEqual(
|
||||
harness.dictionaries().map((entry) => [entry.id, entry.enabled]),
|
||||
[
|
||||
['terms', false],
|
||||
['names', false],
|
||||
['frequency', true],
|
||||
],
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
|
||||
assert.equal(harness.messages.filter((message) => message.type === 'hd_options_write').length, 0);
|
||||
});
|
||||
|
||||
test('Hachidori settings writes survive an empty template list', async () => {
|
||||
const harness = await createHarness();
|
||||
harness.setAnkiTemplates([]);
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer(
|
||||
'http://127.0.0.1:8766',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{
|
||||
forceOverride: true,
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
|
||||
});
|
||||
|
||||
test('Hachidori initializes an empty library before the first dictionary import', async () => {
|
||||
const harness = await createHarness(true);
|
||||
assert.deepEqual(await harness.invoke('getDictionaryInfo'), []);
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer(
|
||||
'http://127.0.0.1:8766',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{ forceOverride: true },
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori restores direct AnkiConnect when disabling its managed proxy', async () => {
|
||||
const harness = await createHarness();
|
||||
const logger = { error: assert.fail };
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
|
||||
forceOverride: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
|
||||
true,
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://127.0.0.1:8765');
|
||||
assert.equal(harness.proxyUrl(), null);
|
||||
});
|
||||
|
||||
test('Hachidori preserves a custom Anki endpoint when disabling its managed proxy', async () => {
|
||||
const harness = await createHarness();
|
||||
const logger = { error: assert.fail };
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
|
||||
forceOverride: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
harness.setAnkiServer('http://192.168.1.10:8765');
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
|
||||
false,
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
|
||||
assert.equal(harness.proxyUrl(), null);
|
||||
});
|
||||
|
||||
test('Hachidori fallback parsing retains token boundaries, inflected readings and headwords', async () => {
|
||||
const harness = await createHarness();
|
||||
const parsed = await requestYomitanParseResults('ミナト 食べた', harness.deps, {
|
||||
error: assert.fail,
|
||||
});
|
||||
const tokens = selectYomitanParseTokens(parsed, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
headword: token.headword,
|
||||
reading: token.reading,
|
||||
start: token.startPos,
|
||||
})),
|
||||
[
|
||||
{ surface: 'ミナト', headword: 'ミナト', reading: 'みなと', start: 0 },
|
||||
{ surface: '食べた', headword: '食べる', reading: 'たべた', start: 4 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori stats mining returns note IDs and prevents duplicate submissions', async () => {
|
||||
const harness = await createHarness();
|
||||
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
|
||||
noteId: 19,
|
||||
duplicateNoteIds: [],
|
||||
});
|
||||
harness.setDuplicate();
|
||||
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
|
||||
noteId: null,
|
||||
duplicateNoteIds: [15],
|
||||
});
|
||||
assert.equal(harness.messages.filter((message) => message.type === 'hd_anki_submit').length, 1);
|
||||
});
|
||||
|
||||
test('Hachidori settings automation imports ZIP bytes and removes the matching dictionary ID', async () => {
|
||||
const harness = await createHarness();
|
||||
await harness.run(
|
||||
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEs=', 'characters.zip')",
|
||||
);
|
||||
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');
|
||||
});
|
||||
|
||||
test('Hachidori waits for a loading engine and retries busy requests before answering', async () => {
|
||||
const harness = await createHarness();
|
||||
harness.setLoadingStatusReplies(2);
|
||||
harness.setBusyEngineReplies(1);
|
||||
const dictionaries = (await harness.invoke('getDictionaryInfo')) as Array<{ title: string }>;
|
||||
assert.equal(dictionaries.length, 3);
|
||||
const statusCalls = harness.messages.filter((message) => message.type === 'hd_status').length;
|
||||
assert.ok(statusCalls >= 3, `expected repeated status polls, saw ${statusCalls}`);
|
||||
const tokens = await requestYomitanScanTokens('食べた', harness.deps, { error: assert.fail });
|
||||
assert.equal(tokens?.[0]?.headword, '食べる');
|
||||
assert.equal(tokens?.[0]?.frequencyRank, 42);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
// Hachidori exposes its own storage and engine protocol. Adapt it only inside
|
||||
// SubMiner's hidden extension windows so the shared scanner keeps its matching,
|
||||
// character-name and frequency semantics without changing Hachidori's pages.
|
||||
import { HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
|
||||
|
||||
export const HACHIDORI_SESSION_PARTITION = 'persist:hachidori';
|
||||
|
||||
export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
|
||||
(async () => {
|
||||
if (globalThis.__subminerDictionarySendMessage) return;
|
||||
const { createApiHost } = await import('./api-host.js');
|
||||
await import('./reader-options.js');
|
||||
const send = async (type, fields = {}, target = 'hoshidicts-offscreen') => {
|
||||
const reply = await chrome.runtime.sendMessage({
|
||||
target, type, requestId: crypto.randomUUID(), ...fields,
|
||||
});
|
||||
if (!reply || reply.ok !== true) {
|
||||
throw new Error(reply?.error || 'Hachidori returned an invalid response');
|
||||
}
|
||||
return reply;
|
||||
};
|
||||
// Hachidori reports an empty library while its dictionaries load and rejects
|
||||
// engine requests during imports. Wait for a settled engine so SubMiner never
|
||||
// caches an empty dictionary list or a token-less scan.
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const waitForEngineReady = async () => {
|
||||
const deadline = Date.now() + 120000;
|
||||
for (;;) {
|
||||
const reply = await send('hd_status').catch(() => null);
|
||||
if (reply?.ready === true && reply.loading !== true) return;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(reply?.ready === true
|
||||
? 'Hachidori is still importing dictionaries' : 'Hachidori dictionary engine is not ready');
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
};
|
||||
const engine = async (type, fields = {}, target) => {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
await waitForEngineReady();
|
||||
try {
|
||||
return await send(type, fields, target);
|
||||
} catch (error) {
|
||||
if (attempt >= 3 || !/busy mutating/.test(error.message)) throw error;
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
};
|
||||
const readState = async () => (await engine('hd_state_read', {}, 'hoshidicts-worker')).state
|
||||
?? { revision: 0, dictionaries: [] };
|
||||
const readOptions = async () => {
|
||||
const stored = (await chrome.storage.local.get('options')).options;
|
||||
return { ...globalThis.HDReaderOptions.normaliseOptions(stored), revision: stored?.revision ?? 0 };
|
||||
};
|
||||
globalThis.__subminerSetAnkiProxyUrl = async url => {
|
||||
const previous = (await chrome.storage.local.get('subminerAnkiProxyUrl')).subminerAnkiProxyUrl;
|
||||
await chrome.storage.local.set({ subminerAnkiProxyUrl: url });
|
||||
return typeof previous === 'string' ? previous : null;
|
||||
};
|
||||
${HACHIDORI_ANKI_SETTINGS_SCRIPT}
|
||||
const api = createApiHost({
|
||||
engine: fields => engine(fields.type, fields),
|
||||
render: fields => send(fields.type, fields, 'hachidori-anki-render'),
|
||||
readDictionaries: async () => (await readState()).dictionaries,
|
||||
readAudioSources: async () => (await readOptions()).audioSources.filter(source => source.enabled),
|
||||
version: chrome.runtime.getManifest().version,
|
||||
});
|
||||
// Snapshot of the last projection SubMiner received, so a later write can
|
||||
// be reduced to the fields SubMiner actually changed.
|
||||
let lastProjection = null;
|
||||
async function optionsGetFull() {
|
||||
const [state, options] = await Promise.all([readState(), readOptions()]);
|
||||
const revisions = { dictionaries: state.revision, options: options.revision };
|
||||
lastProjection = {
|
||||
revisions,
|
||||
enabledByTitle: Object.fromEntries(state.dictionaries.map(entry => [entry.title, entry.enabled !== false])),
|
||||
server: options.anki.url,
|
||||
deckByFormat: Object.fromEntries(options.anki.templates.map(template => [template.id, template.deck])),
|
||||
};
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
hachidoriRevisions: revisions,
|
||||
profiles: [{ name: 'Hachidori', options: {
|
||||
scanning: { length: options.scanLength },
|
||||
dictionaries: state.dictionaries.map((entry, index) => ({
|
||||
name: entry.title, alias: entry.displayName || entry.title,
|
||||
id: index, enabled: entry.enabled !== false,
|
||||
})),
|
||||
anki: {
|
||||
server: options.anki.url,
|
||||
cardFormats: options.anki.templates.map(template => ({
|
||||
id: template.id, type: 'term', enabled: true, deck: template.deck,
|
||||
})),
|
||||
},
|
||||
} }],
|
||||
};
|
||||
}
|
||||
async function setAllSettings(value) {
|
||||
const projected = value.profiles?.[0]?.options;
|
||||
const revisions = value.hachidoriRevisions;
|
||||
if (!projected || !revisions) throw new Error('Invalid Hachidori settings projection');
|
||||
const base = lastProjection
|
||||
&& lastProjection.revisions.dictionaries === revisions.dictionaries
|
||||
&& lastProjection.revisions.options === revisions.options ? lastProjection : null;
|
||||
const enabledByTitle = Object.fromEntries(projected.dictionaries.map(item => [item.name, item.enabled === true]));
|
||||
const deckByFormat = Object.fromEntries(projected.anki.cardFormats.map(format => [format.id, format.deck]));
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const [state, options] = await Promise.all([readState(), readOptions()]);
|
||||
const current = state.revision === revisions.dictionaries && options.revision === revisions.options;
|
||||
if (!current && !base) {
|
||||
throw new Error('Hachidori settings changed while SubMiner was updating them; retry the action');
|
||||
}
|
||||
// Once Hachidori has moved on, apply only SubMiner's own changes on top
|
||||
// of the newer settings instead of replaying the stale projection.
|
||||
const enabledFor = title => enabledByTitle[title] === true;
|
||||
const dictionaries = state.dictionaries.map(entry =>
|
||||
current || (base.enabledByTitle[entry.title] === true) !== enabledFor(entry.title)
|
||||
? { ...entry, enabled: enabledFor(entry.title) } : entry);
|
||||
const templates = options.anki.templates.map(template => {
|
||||
const deck = deckByFormat[template.id];
|
||||
return deck !== undefined && (current || base.deckByFormat[template.id] !== deck)
|
||||
? { ...template, deck } : template;
|
||||
});
|
||||
const url = current || base.server !== projected.anki.server ? projected.anki.server : options.anki.url;
|
||||
const anki = { ...options.anki, url, templates, deck: templates[0]?.deck ?? options.anki.deck };
|
||||
try {
|
||||
if (JSON.stringify(dictionaries) !== JSON.stringify(state.dictionaries)) {
|
||||
await engine('hd_apply_state', { baseRevision: state.revision, dictionaries });
|
||||
}
|
||||
if (JSON.stringify(anki) !== JSON.stringify(options.anki)) {
|
||||
await engine('hd_options_write', { baseRevision: options.revision, options: { anki } }, 'hoshidicts-worker');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
// A write raced another Hachidori change; re-read once and reapply the delta.
|
||||
if (attempt > 0 || !base) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function getTermFrequencies({ termReadingList, dictionaries }) {
|
||||
let frequencies;
|
||||
try {
|
||||
({ frequencies } = await engine('hd_frequencies', { termReadingList }));
|
||||
} catch (error) {
|
||||
const { sharing } = await send('hd_sharing_status', {}, 'hachidori-sharing');
|
||||
if (!sharing?.client?.connected || !/unknown|unsupported|not supported/i.test(error.message)) throw error;
|
||||
// Older external hosts expose frequency data through term lookups only.
|
||||
frequencies = [];
|
||||
for (const { term, reading } of termReadingList) {
|
||||
const { results } = await engine('hd_lookup', { text: term, maxResults: 100 });
|
||||
for (const result of results) {
|
||||
if (result.term.expression !== term || (reading !== null && result.term.reading !== reading)) continue;
|
||||
for (const group of result.term.frequencies) {
|
||||
for (const value of group.frequencies) {
|
||||
frequencies.push({ term, reading: value.reading || null,
|
||||
hasReading: typeof value.reading === 'string' && value.reading.length > 0,
|
||||
dictionary: group.dictionary, frequency: value.value,
|
||||
displayValue: value.displayValue || null, displayValueParsed: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return frequencies.filter(frequency => dictionaries.includes(frequency.dictionary));
|
||||
}
|
||||
// Hachidori's public tokenize API emits display furigana without headwords.
|
||||
// SubMiner's fallback requires one group per token and a dictionary form.
|
||||
async function parseText({ text, scanLength }) {
|
||||
const content = [];
|
||||
let position = 0;
|
||||
while (position < text.length) {
|
||||
const rest = text.slice(position);
|
||||
const reply = await engine('hd_lookup', { text: rest, maxResults: 1, scanLength });
|
||||
const result = reply.results[0];
|
||||
if (!result?.matched || !rest.startsWith(result.matched)) {
|
||||
const character = String.fromCodePoint(rest.codePointAt(0));
|
||||
content.push([{ text: character, reading: '' }]);
|
||||
position += character.length;
|
||||
continue;
|
||||
}
|
||||
const term = result.term;
|
||||
// Keep the inflected ending in the reading, just as the main scanner does.
|
||||
let stem = 0;
|
||||
while (stem < term.expression.length && stem < result.matched.length && term.expression[stem] === result.matched[stem]) stem += 1;
|
||||
const ending = term.expression.slice(stem);
|
||||
const reading = stem > 0 && term.reading.endsWith(ending)
|
||||
? term.reading.slice(0, term.reading.length - ending.length) + result.matched.slice(stem)
|
||||
: term.reading;
|
||||
content.push([{ text: result.matched, reading, headwords: [[{ term: term.expression }]] }]);
|
||||
position += result.matched.length;
|
||||
}
|
||||
return [{ source: 'scanning-parser', index: 0, content }];
|
||||
}
|
||||
async function invoke(action, params) {
|
||||
switch (action) {
|
||||
case 'optionsGetFull': return optionsGetFull();
|
||||
case 'setAllSettings': return setAllSettings(params.value);
|
||||
case 'getDictionaryInfo': return (await readState()).dictionaries.map(entry => ({
|
||||
title: entry.title, revision: entry.revision, frequencyMode: entry.frequencyMode,
|
||||
}));
|
||||
case 'termsFind': {
|
||||
const reply = await api({ type: 'hd_api_term_entries', terms: [params.text] });
|
||||
return reply.results[0];
|
||||
}
|
||||
case 'parseText': return parseText(params);
|
||||
case 'getTermFrequencies': return getTermFrequencies(params);
|
||||
default: throw new Error('Unsupported Hachidori parser action: ' + action);
|
||||
}
|
||||
}
|
||||
globalThis.__subminerDictionarySendMessage = ({ action, params }, callback) => {
|
||||
void invoke(action, params).then(result => callback({ result }), error => callback({ error: { message: error.message } }));
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
globalThis.__subminerYomitanSettingsAutomation = {
|
||||
ready: true,
|
||||
async importDictionaryArchiveUrl(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Could not read the dictionary archive');
|
||||
await importArchive(await response.blob(), 'subminer-dictionary.zip');
|
||||
},
|
||||
async importDictionaryArchiveBase64(base64, fileName) {
|
||||
const bytes = Uint8Array.from(atob(base64), character => character.charCodeAt(0));
|
||||
await importArchive(new Blob([bytes], { type: 'application/zip' }), fileName);
|
||||
},
|
||||
async deleteDictionary(title) {
|
||||
const dictionary = (await readState()).dictionaries.find(entry => entry.title === title);
|
||||
if (dictionary) await engine('hd_remove', { id: dictionary.id, title });
|
||||
},
|
||||
};
|
||||
globalThis.__subminerAddNote = async word => {
|
||||
const lookup = await engine('hd_lookup', { text: word, maxResults: 1 });
|
||||
const result = lookup.results[0];
|
||||
if (!result) return { noteId: null, duplicateNoteIds: [] };
|
||||
const status = await send('hd_anki_status', {}, 'hachidori-anki');
|
||||
const request = {
|
||||
...result, generation: lookup.generation, sentence: word, searchQuery: word,
|
||||
matchOffset: 0, documentTitle: 'SubMiner', popupSelectionText: '',
|
||||
configKey: status.configKey,
|
||||
captureUnavailable: ['screenshot', 'animation', 'audio'],
|
||||
};
|
||||
const preflight = await send('hd_anki_preflight', { request }, 'hachidori-anki');
|
||||
if (preflight.canAdd !== true) {
|
||||
if (preflight.state !== 'duplicate') throw new Error(preflight.error || 'Hachidori Anki mining is unavailable');
|
||||
return { noteId: null, duplicateNoteIds: preflight.noteIds ?? [] };
|
||||
}
|
||||
const submitted = await send('hd_anki_submit', { request }, 'hachidori-anki');
|
||||
if (submitted.state === 'added' || submitted.state === 'updated') {
|
||||
return { noteId: submitted.noteId, duplicateNoteIds: [] };
|
||||
}
|
||||
if (submitted.state === 'duplicate') return { noteId: null, duplicateNoteIds: submitted.noteIds ?? [] };
|
||||
throw new Error(submitted.error || 'Hachidori could not confirm the note write');
|
||||
};
|
||||
})();
|
||||
`;
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { AnkiConnectConfig } from '../../../types';
|
||||
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
|
||||
import {
|
||||
buildHachidoriSharingScript,
|
||||
parseHachidoriHostStatus,
|
||||
type HachidoriSharingRequest,
|
||||
} from '../../../shared/hachidori-sharing';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
HACHIDORI_PARSER_BRIDGE_SCRIPT,
|
||||
HACHIDORI_SESSION_PARTITION,
|
||||
} from './hachidori-parser-bridge';
|
||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||
import {
|
||||
buildYomitanScanCallScript,
|
||||
@@ -477,7 +488,7 @@ async function requestYomitanProfileMetadata(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -596,6 +607,18 @@ function logYomitanProfileDiagnostics(
|
||||
logger.info?.('Yomitan active profile dictionaries loaded.', details);
|
||||
}
|
||||
|
||||
function isHachidoriExtension(extension: Extension): boolean {
|
||||
return extension.name === 'Hachidori';
|
||||
}
|
||||
|
||||
// Without an explicit session, use the one the extension was loaded into:
|
||||
// Hachidori lives in its own partition, Yomitan in the default session.
|
||||
function resolveBackendSession(electron: typeof import('electron'), extension: Extension): Session {
|
||||
return isHachidoriExtension(extension)
|
||||
? electron.session.fromPartition(HACHIDORI_SESSION_PARTITION)
|
||||
: electron.session.defaultSession;
|
||||
}
|
||||
|
||||
async function ensureYomitanParserWindow(
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
@@ -606,19 +629,20 @@ async function ensureYomitanParserWindow(
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentWindow = deps.getYomitanParserWindow();
|
||||
if (currentWindow && !currentWindow.isDestroyed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const existingInitPromise = deps.getYomitanParserInitPromise();
|
||||
if (existingInitPromise) {
|
||||
return existingInitPromise;
|
||||
}
|
||||
|
||||
const currentWindow = deps.getYomitanParserWindow();
|
||||
if (currentWindow && !currentWindow.isDestroyed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const initPromise = (async () => {
|
||||
const { BrowserWindow, session } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||
const { BrowserWindow } = electron;
|
||||
const yomitanSession =
|
||||
deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
|
||||
const parserWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
@@ -649,11 +673,15 @@ async function ensureYomitanParserWindow(
|
||||
});
|
||||
|
||||
try {
|
||||
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`);
|
||||
const parserPage = isHachidoriExtension(yomitanExt) ? 'settings.html' : 'search.html';
|
||||
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/${parserPage}`);
|
||||
const readyPromise = deps.getYomitanParserReadyPromise();
|
||||
if (readyPromise) {
|
||||
await readyPromise;
|
||||
}
|
||||
if (isHachidoriExtension(yomitanExt)) {
|
||||
await parserWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
|
||||
}
|
||||
// Eagerly install the scan runtime so the first subtitle line does not
|
||||
// pay the install round trip; failures fall back to the per-request
|
||||
// install-and-retry path.
|
||||
@@ -696,8 +724,8 @@ async function createYomitanExtensionWindow(
|
||||
return null;
|
||||
}
|
||||
|
||||
const { BrowserWindow, session } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||
const { BrowserWindow } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1200,
|
||||
@@ -740,6 +768,10 @@ async function invokeYomitanSettingsAutomation<T>(
|
||||
}
|
||||
|
||||
try {
|
||||
const extension = deps.getYomitanExt();
|
||||
if (extension && isHachidoriExtension(extension)) {
|
||||
await settingsWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
|
||||
}
|
||||
await settingsWindow.webContents.executeJavaScript(
|
||||
`
|
||||
(async () => {
|
||||
@@ -892,7 +924,7 @@ export async function requestYomitanParseResults(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -922,7 +954,7 @@ export async function requestYomitanParseResults(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1079,7 +1111,7 @@ async function fetchYomitanTermFrequencies(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1122,7 +1154,7 @@ async function fetchYomitanTermFrequencies(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1206,7 +1238,17 @@ function cacheFrequencyEntriesForPairs(
|
||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||
const exactEntries = groupedByPair.get(key);
|
||||
const termEntries = groupedByTerm.get(pair.term) ?? [];
|
||||
frequencyCache.set(key, exactEntries ?? termEntries);
|
||||
// Untagged frequency rows apply to every reading. A term-only query must
|
||||
// retain all readings, rather than selecting only its untagged rows.
|
||||
const untaggedEntries = groupedByPair.get(makeTermReadingCacheKey(pair.term, null)) ?? [];
|
||||
frequencyCache.set(
|
||||
key,
|
||||
pair.reading === null
|
||||
? termEntries
|
||||
: exactEntries
|
||||
? [...exactEntries, ...untaggedEntries]
|
||||
: termEntries,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1340,6 +1382,7 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
options?: {
|
||||
forceOverride?: boolean;
|
||||
deck?: string;
|
||||
ankiConfig?: AnkiConnectConfig;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const normalizedTargetServer = serverUrl.trim();
|
||||
@@ -1359,7 +1402,7 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1379,6 +1422,16 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
||||
const targetDeck = ${JSON.stringify(normalizedTargetDeck)};
|
||||
const forceOverride = ${forceOverride ? 'true' : 'false'};
|
||||
const hachidoriHints = ${JSON.stringify(options?.ankiConfig ? buildHachidoriAnkiHints(options.ankiConfig) : null)};
|
||||
if (hachidoriHints && typeof globalThis.__subminerSyncAnkiSettings === 'function') {
|
||||
return globalThis.__subminerSyncAnkiSettings({
|
||||
server: targetServer, deck: targetDeck, forceOverride, hints: hachidoriHints,
|
||||
});
|
||||
}
|
||||
let previousManagedProxy = null;
|
||||
if (typeof globalThis.__subminerSetAnkiProxyUrl === 'function') {
|
||||
previousManagedProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? targetServer : null);
|
||||
}
|
||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
if (profiles.length === 0) {
|
||||
@@ -1405,7 +1458,8 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
let changed = false;
|
||||
if (currentServer !== targetServer) {
|
||||
const canReplaceCurrent =
|
||||
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
||||
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765" ||
|
||||
(typeof previousManagedProxy === 'string' && currentServer === previousManagedProxy);
|
||||
if (!canReplaceCurrent) {
|
||||
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
|
||||
}
|
||||
@@ -1455,6 +1509,12 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
|
||||
try {
|
||||
const result = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
if (isObject(result) && result.pending === true) {
|
||||
logger.info?.(
|
||||
'Anki is unavailable; Hachidori field auto-population will retry when opened again',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const updated =
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
@@ -1564,7 +1624,7 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1586,6 +1646,25 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
|
||||
`;
|
||||
}
|
||||
|
||||
export async function requestHachidoriSharing(
|
||||
request: HachidoriSharingRequest,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
) {
|
||||
const extension = deps.getYomitanExt();
|
||||
if (!extension || !isHachidoriExtension(extension)) throw new Error('Hachidori is not active.');
|
||||
const ready = await ensureYomitanParserWindow(deps, logger);
|
||||
const window = deps.getYomitanParserWindow();
|
||||
if (!ready || !window || window.isDestroyed()) throw new Error('Hachidori is unavailable.');
|
||||
const reply: unknown = await window.webContents.executeJavaScript(
|
||||
buildHachidoriSharingScript(request),
|
||||
true,
|
||||
);
|
||||
const status = parseHachidoriHostStatus(reply);
|
||||
if (request.type !== 'hd_sharing_status') clearYomitanParserCachesForWindow(window);
|
||||
return status;
|
||||
}
|
||||
|
||||
async function invokeYomitanBackendAction<T>(
|
||||
action: string,
|
||||
params: unknown,
|
||||
|
||||
@@ -12,7 +12,7 @@ export type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
|
||||
|
||||
// Bump whenever the install script below changes so already-loaded parser
|
||||
// windows re-install the new scan runtime instead of running the stale one.
|
||||
export const YOMITAN_SCAN_RUNTIME_VERSION = 12;
|
||||
export const YOMITAN_SCAN_RUNTIME_VERSION = 13;
|
||||
export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
|
||||
|
||||
export interface YomitanScanRequestParams {
|
||||
@@ -43,7 +43,7 @@ export const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
|
||||
}
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,7 @@ type ExtensionCopyResult = {
|
||||
|
||||
type ExtensionCopyOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
extensionName?: 'yomitan' | 'hachidori';
|
||||
};
|
||||
|
||||
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
|
||||
@@ -156,7 +157,7 @@ export function ensureExtensionCopy(
|
||||
}
|
||||
|
||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
|
||||
|
||||
let shouldCopy = !fs.existsSync(targetDir);
|
||||
if (!shouldCopy) {
|
||||
@@ -182,7 +183,7 @@ export async function ensureExtensionCopyAsync(
|
||||
}
|
||||
|
||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
|
||||
const inFlightKey = path.resolve(targetDir);
|
||||
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
|
||||
if (inFlight) {
|
||||
|
||||
@@ -7,6 +7,7 @@ const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = el
|
||||
const logger = createLogger('main:yomitan-settings');
|
||||
|
||||
export interface OpenYomitanSettingsWindowOptions {
|
||||
backend?: 'yomitan' | 'hachidori';
|
||||
yomitanExt: Extension | null;
|
||||
getExistingWindow: () => BrowserWindow | null;
|
||||
setWindow: (window: BrowserWindow | null) => void;
|
||||
@@ -23,6 +24,7 @@ type HyprlandSessionEnv = {
|
||||
export interface InstallYomitanSettingsCloseButtonOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: HyprlandSessionEnv;
|
||||
backend?: 'yomitan' | 'hachidori';
|
||||
}
|
||||
|
||||
export function shouldInstallYomitanSettingsCloseButton(
|
||||
@@ -53,7 +55,9 @@ export function buildYomitanSettingsWindowMenuTemplate(
|
||||
];
|
||||
}
|
||||
|
||||
export function buildYomitanSettingsCloseButtonScript(): string {
|
||||
export function buildYomitanSettingsCloseButtonScript(
|
||||
backend: 'yomitan' | 'hachidori' = 'yomitan',
|
||||
): string {
|
||||
return `
|
||||
(() => {
|
||||
const buttonId = 'subminer-yomitan-settings-close';
|
||||
@@ -97,7 +101,7 @@ export function buildYomitanSettingsCloseButtonScript(): string {
|
||||
button.id = buttonId;
|
||||
button.type = 'button';
|
||||
button.title = 'Close';
|
||||
button.setAttribute('aria-label', 'Close Yomitan settings');
|
||||
button.setAttribute('aria-label', 'Close ${backend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings');
|
||||
button.textContent = '\\u00d7';
|
||||
button.addEventListener('click', () => {
|
||||
window.close();
|
||||
@@ -118,7 +122,7 @@ export function installYomitanSettingsCloseButton(
|
||||
return;
|
||||
}
|
||||
settingsWindow.webContents
|
||||
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
|
||||
.executeJavaScript(buildYomitanSettingsCloseButtonScript(options.backend))
|
||||
.catch((error: Error) => {
|
||||
logger.warn('Failed to install Yomitan settings close button:', error.message);
|
||||
});
|
||||
@@ -184,7 +188,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
|
||||
|
||||
const settingsWindow = new ElectronBrowserWindow({
|
||||
title: 'Yomitan Settings',
|
||||
title: options.backend === 'hachidori' ? 'Hachidori Settings' : 'Yomitan Settings',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
@@ -228,7 +232,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
|
||||
settingsWindow.webContents.on('did-finish-load', () => {
|
||||
logger.info('Settings page loaded successfully');
|
||||
installYomitanSettingsCloseButton(settingsWindow);
|
||||
installYomitanSettingsCloseButton(settingsWindow, { backend: options.backend });
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
+162
-7
@@ -1,3 +1,4 @@
|
||||
import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime';
|
||||
/*
|
||||
SubMiner - All-in-one sentence mining overlay
|
||||
Copyright (C) 2026 sudacode
|
||||
@@ -32,6 +33,14 @@ import {
|
||||
screen,
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import {
|
||||
createHachidoriExtensionRuntime,
|
||||
getHachidoriSession,
|
||||
} from './core/services/hachidori-extension';
|
||||
import {
|
||||
DICTIONARY_EXTERNAL_LINK_CHANNEL,
|
||||
parseDictionaryExternalUrl,
|
||||
} from './shared/dictionary-external-link';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
|
||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||
@@ -906,14 +915,34 @@ const {
|
||||
appState,
|
||||
appLifecycleApp,
|
||||
} = bootServices;
|
||||
// Backend changes take effect on restart; windows and parser must share one session.
|
||||
const activeDictionaryBackend = configService.getConfig().dictionaryBackend;
|
||||
const hachidoriExtensionRuntime = createHachidoriExtensionRuntime(USER_DATA_PATH);
|
||||
let hachidoriSettingsWindow: BrowserWindow | null = null;
|
||||
let inactiveYomitanExtension: Extension | null = null;
|
||||
let inactiveYomitanSettingsWindow: BrowserWindow | null = null;
|
||||
let inactiveYomitanLoad: Promise<Extension | null> | null = null;
|
||||
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
|
||||
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
|
||||
ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => {
|
||||
if (
|
||||
activeDictionaryBackend !== 'hachidori' ||
|
||||
event.senderFrame !== event.sender.mainFrame ||
|
||||
!overlayManager.getOverlayWindows().some((window) => window.webContents === event.sender)
|
||||
) {
|
||||
throw new Error('Dictionary links are only available from the active overlay');
|
||||
}
|
||||
await shell.openExternal(parseDictionaryExternalUrl(value));
|
||||
});
|
||||
|
||||
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
|
||||
return [
|
||||
appState.configSettingsWindow,
|
||||
appState.syncUiWindow,
|
||||
appState.yomitanSettingsWindow,
|
||||
hachidoriSettingsWindow,
|
||||
inactiveYomitanSettingsWindow,
|
||||
appState.anilistSetupWindow,
|
||||
appState.jellyfinSetupWindow,
|
||||
appState.firstRunSetupWindow,
|
||||
@@ -1011,6 +1040,8 @@ const {
|
||||
} = statsServerRuntime;
|
||||
|
||||
function requestAppQuit(): void {
|
||||
destroyYomitanSettingsWindow(hachidoriSettingsWindow);
|
||||
destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow);
|
||||
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
||||
appState.yomitanSettingsWindow = null;
|
||||
destroyStatsWindow();
|
||||
@@ -1455,6 +1486,15 @@ const createCommandLineLauncherRuntimeOptions = () => ({
|
||||
: undefined,
|
||||
});
|
||||
const firstRunSetupService = createFirstRunSetupService({
|
||||
getDictionaryBackend: () => activeDictionaryBackend,
|
||||
getHachidoriHostStatus: async () => {
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return requestHachidoriSharing(
|
||||
{ type: 'hd_sharing_status' },
|
||||
getYomitanParserRuntimeDeps(),
|
||||
logger,
|
||||
);
|
||||
},
|
||||
platform: process.platform,
|
||||
configDir: CONFIG_DIR,
|
||||
getYomitanDictionaryCount: async () => {
|
||||
@@ -2277,6 +2317,11 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) {
|
||||
void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) =>
|
||||
logger.error('Failed to auto-populate Hachidori Anki settings', error),
|
||||
);
|
||||
}
|
||||
},
|
||||
invalidateTokenizationCache: () => {
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
@@ -3450,6 +3495,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
return {
|
||||
configReady: snapshot.configReady,
|
||||
dictionaryCount: snapshot.dictionaryCount,
|
||||
dictionaryBackend: snapshot.dictionaryBackend,
|
||||
hachidoriHost: snapshot.hachidoriHost,
|
||||
canFinish: snapshot.canFinish,
|
||||
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||
pluginStatus: snapshot.pluginStatus,
|
||||
@@ -3465,6 +3512,33 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
||||
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
||||
handleAction: async (submission: FirstRunSetupSubmission) => {
|
||||
if (
|
||||
submission.action === 'link-hachidori-host' ||
|
||||
submission.action === 'unlink-hachidori-host'
|
||||
) {
|
||||
try {
|
||||
if (activeDictionaryBackend !== 'hachidori')
|
||||
throw new Error('Select Hachidori and restart SubMiner before linking a host.');
|
||||
await ensureYomitanExtensionLoaded();
|
||||
const status = await requestHachidoriSharing(
|
||||
submission.action === 'link-hachidori-host'
|
||||
? { type: 'hd_sharing_client_link', address: submission.address }
|
||||
: { type: 'hd_sharing_client_unlink' },
|
||||
getYomitanParserRuntimeDeps(),
|
||||
logger,
|
||||
);
|
||||
firstRunSetupMessage =
|
||||
status.kind === 'local'
|
||||
? 'Using dictionaries installed in SubMiner.'
|
||||
: status.kind === 'connected'
|
||||
? `Linked to ${status.name}. Anki mining stays in SubMiner.`
|
||||
: status.message;
|
||||
} catch (error) {
|
||||
firstRunSetupMessage =
|
||||
error instanceof Error ? error.message : 'Could not update the dictionary host.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'remove-legacy-plugin') {
|
||||
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
@@ -3505,8 +3579,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'open-yomitan-settings') {
|
||||
firstRunSetupMessage = openYomitanSettings()
|
||||
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
|
||||
firstRunSetupMessage = openDictionarySettings()
|
||||
? `Opened ${activeDictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.`
|
||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||
return;
|
||||
}
|
||||
@@ -5157,6 +5231,41 @@ function initializeOverlayRuntime(): void {
|
||||
}
|
||||
|
||||
function openYomitanSettings(): boolean {
|
||||
if (activeDictionaryBackend === 'hachidori') {
|
||||
if (configService.getConfig().yomitan.externalProfilePath.trim()) {
|
||||
logger.warn('Yomitan settings unavailable while using read-only external-profile mode.');
|
||||
return false;
|
||||
}
|
||||
inactiveYomitanLoad ??= inactiveYomitanExtension
|
||||
? Promise.resolve(inactiveYomitanExtension)
|
||||
: loadYomitanExtensionCore({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
setYomitanParserInitPromise: () => {},
|
||||
setYomitanExtension: (extension) => {
|
||||
inactiveYomitanExtension = extension;
|
||||
},
|
||||
setYomitanSession: () => {},
|
||||
}).finally(() => {
|
||||
inactiveYomitanLoad = null;
|
||||
});
|
||||
void (
|
||||
inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad
|
||||
)
|
||||
.then((extension) =>
|
||||
openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: () => inactiveYomitanSettingsWindow,
|
||||
setWindow: (window) => {
|
||||
inactiveYomitanSettingsWindow = window;
|
||||
},
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => logger.error('Failed to open Yomitan settings', error));
|
||||
return true;
|
||||
}
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||
logger.warn(
|
||||
@@ -5169,6 +5278,39 @@ function openYomitanSettings(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function openHachidoriSettings(): void {
|
||||
void (
|
||||
activeDictionaryBackend === 'hachidori'
|
||||
? ensureYomitanExtensionLoaded()
|
||||
: hachidoriExtensionRuntime.ensureLoaded()
|
||||
)
|
||||
.then((extension) =>
|
||||
openYomitanSettingsWindow({
|
||||
backend: 'hachidori',
|
||||
yomitanExt: extension,
|
||||
yomitanSession: getHachidoriSession(),
|
||||
getExistingWindow: () => hachidoriSettingsWindow,
|
||||
setWindow: (window) => {
|
||||
hachidoriSettingsWindow = window;
|
||||
},
|
||||
onWindowClosed: () => {
|
||||
if (activeDictionaryBackend === 'hachidori' && appState.yomitanParserWindow) {
|
||||
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => logger.error('Failed to open Hachidori settings', error));
|
||||
}
|
||||
|
||||
function openDictionarySettings(): boolean {
|
||||
if (activeDictionaryBackend === 'hachidori') {
|
||||
openHachidoriSettings();
|
||||
return true;
|
||||
}
|
||||
return openYomitanSettings();
|
||||
}
|
||||
|
||||
const { exportLogsFromTray } = createLogExportTrayRuntime({
|
||||
flushMpvLog: () => flushMpvLog(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
@@ -5196,7 +5338,7 @@ const {
|
||||
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
|
||||
registerGlobalShortcutsCore,
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openYomitanSettings: () => openDictionarySettings(),
|
||||
isDev,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
}),
|
||||
@@ -5687,7 +5829,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
},
|
||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openYomitanSettings: () => openDictionarySettings(),
|
||||
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
|
||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||
// when extraction finishes long after playback has moved on.
|
||||
@@ -6128,6 +6270,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
||||
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||
openHachidoriSettings: () => openHachidoriSettings(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||
@@ -6303,7 +6446,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
getYomitanSession: () =>
|
||||
activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
@@ -6391,6 +6535,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
getDictionaryBackend: () => activeDictionaryBackend,
|
||||
openHachidoriSettings: () => openHachidoriSettings(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||
@@ -6435,12 +6581,21 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
|
||||
});
|
||||
const yomitanProfilePolicy = createYomitanProfilePolicy({
|
||||
externalProfilePath: configService.getConfig().yomitan.externalProfilePath,
|
||||
externalProfilePath:
|
||||
activeDictionaryBackend === 'yomitan'
|
||||
? configService.getConfig().yomitan.externalProfilePath
|
||||
: '',
|
||||
logInfo: (message) => logger.info(message),
|
||||
});
|
||||
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
|
||||
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore,
|
||||
loadYomitanExtensionCore: async (deps) => {
|
||||
if (activeDictionaryBackend === 'yomitan') return loadYomitanExtensionCore(deps);
|
||||
const extension = await hachidoriExtensionRuntime.ensureLoaded();
|
||||
deps.setYomitanExtension(extension);
|
||||
deps.setYomitanSession(getHachidoriSession());
|
||||
return extension;
|
||||
},
|
||||
userDataPath: USER_DATA_PATH,
|
||||
externalProfilePath: configuredExternalYomitanProfilePath,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -135,6 +136,7 @@ function createCliCommandDepsFromContext(
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
openHachidoriSettings: context.openHachidoriSettings,
|
||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||
openSyncUiWindow: context.openSyncUiWindow,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
|
||||
@@ -217,6 +217,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
ui: {
|
||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
openHachidoriSettings: CliCommandDepsRuntimeOptions['ui']['openHachidoriSettings'];
|
||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
@@ -430,6 +431,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
ui: {
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
openHachidoriSettings: params.ui.openHachidoriSettings,
|
||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: params.ui.openSyncUiWindow,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
|
||||
@@ -18,6 +18,7 @@ type MockWindow = {
|
||||
contentReady: boolean;
|
||||
documentLoaded: boolean;
|
||||
loadCallbacks: Array<() => void>;
|
||||
stopLoadingCallbacks: Array<() => void>;
|
||||
readyToShowCallbacks: Array<() => void>;
|
||||
};
|
||||
|
||||
@@ -37,6 +38,7 @@ function createMockWindow(): MockWindow & {
|
||||
destroy: () => void;
|
||||
focus: () => void;
|
||||
emitDidFinishLoad: () => void;
|
||||
emitDidStopLoading: () => void;
|
||||
emitReadyToShow: () => void;
|
||||
once: (event: 'ready-to-show', cb: () => void) => void;
|
||||
webContents: {
|
||||
@@ -45,7 +47,7 @@ function createMockWindow(): MockWindow & {
|
||||
getURL: () => string;
|
||||
send: (channel: string, payload?: unknown) => void;
|
||||
isFocused: () => boolean;
|
||||
once: (event: 'did-finish-load', cb: () => void) => void;
|
||||
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => void;
|
||||
focus: () => void;
|
||||
};
|
||||
} {
|
||||
@@ -65,6 +67,7 @@ function createMockWindow(): MockWindow & {
|
||||
contentReady: true,
|
||||
documentLoaded: true,
|
||||
loadCallbacks: [],
|
||||
stopLoadingCallbacks: [],
|
||||
readyToShowCallbacks: [],
|
||||
};
|
||||
const window = {
|
||||
@@ -112,6 +115,13 @@ function createMockWindow(): MockWindow & {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
emitDidStopLoading: () => {
|
||||
state.loading = false;
|
||||
const callbacks = state.stopLoadingCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
emitReadyToShow: () => {
|
||||
const callbacks = state.readyToShowCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
@@ -133,7 +143,11 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
focused: false,
|
||||
isFocused: () => state.webContentsFocused,
|
||||
once: (_event: 'did-finish-load', cb: () => void) => {
|
||||
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => {
|
||||
if (event === 'did-stop-loading') {
|
||||
state.stopLoadingCallbacks.push(cb);
|
||||
return;
|
||||
}
|
||||
state.loadCallbacks.push(cb);
|
||||
},
|
||||
focus: () => {
|
||||
@@ -892,6 +906,44 @@ test('sendToActiveOverlayWindow delivers on first modal load without waiting for
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow delivers to a modal window created for the send', () => {
|
||||
// Electron keeps isLoading() true inside did-finish-load, and ready-to-show can fire
|
||||
// before it; only did-stop-loading sees the settled state.
|
||||
const window = createMockWindow();
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
window.contentReady = false;
|
||||
window.documentLoaded = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'media-timing-review:open',
|
||||
{ reviewId: 'r1' },
|
||||
{
|
||||
restoreOnModalClose: 'media-timing-review',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.documentLoaded = true;
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.emitDidStopLoading();
|
||||
assert.deepEqual(window.sent, [['media-timing-review:open', { reviewId: 'r1' }]]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
|
||||
@@ -206,6 +206,7 @@ export function createOverlayModalRuntimeService(
|
||||
if (!modalWindowPrimeListenersRegistered.has(modalWindow)) {
|
||||
modalWindowPrimeListenersRegistered.add(modalWindow);
|
||||
modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow));
|
||||
modalWindow.webContents.once('did-stop-loading', () => markModalWindowPrimed(modalWindow));
|
||||
modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow));
|
||||
}
|
||||
return true;
|
||||
@@ -281,7 +282,11 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
// A hidden macOS panel may not emit ready-to-show until it is presented. The
|
||||
// renderer can safely receive IPC as soon as its document has finished loading.
|
||||
// Electron still reports isLoading() inside did-finish-load (and ready-to-show can
|
||||
// fire even earlier), so a window created for this send would drop the message
|
||||
// without the did-stop-loading pass that follows once the load state settles.
|
||||
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
|
||||
window.webContents.once('did-stop-loading', () => deliver(() => isWindowLoadedForIpc(window)));
|
||||
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
|
||||
deliver(() => isWindowLoadedForIpc(window));
|
||||
};
|
||||
|
||||
@@ -72,6 +72,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -103,6 +103,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
openHachidoriSettings: () => deps.openHachidoriSettings(),
|
||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
|
||||
@@ -56,6 +56,7 @@ function createDeps() {
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -52,6 +52,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -134,6 +135,7 @@ export function createCliCommandContext(
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -50,6 +50,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -6,6 +6,11 @@ import path from 'node:path';
|
||||
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
|
||||
import {
|
||||
createDefaultSetupState,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
} from '../../shared/setup-state';
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
|
||||
@@ -30,6 +35,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
@@ -762,3 +768,115 @@ test('setup service reports failed legacy mpv plugin trash paths', async () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('switching to Hachidori requires its own dictionaries and persists backend readiness', async () => {
|
||||
await withTempDir(async (configDir) => {
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
const yomitan = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
|
||||
let dictionaryCount = 0;
|
||||
const hachidori = createFirstRunSetupService({
|
||||
configDir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getYomitanDictionaryCount: async () => dictionaryCount,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const initial = await hachidori.ensureSetupStateInitialized();
|
||||
assert.equal(initial.dictionaryBackend, 'hachidori');
|
||||
assert.equal(initial.state.dictionaryBackend, 'hachidori');
|
||||
assert.equal(initial.canFinish, false);
|
||||
assert.equal(initial.externalYomitanConfigured, false);
|
||||
assert.equal(initial.state.status, 'incomplete');
|
||||
dictionaryCount = 1;
|
||||
const completed = await hachidori.markSetupCompleted();
|
||||
assert.equal(completed.state.status, 'completed');
|
||||
assert.equal(completed.state.dictionaryBackend, 'hachidori');
|
||||
assert.equal(completed.state.lastSeenYomitanDictionaryCount, 1);
|
||||
assert.equal(hachidori.isSetupCompleted(), true);
|
||||
assert.deepEqual(completed.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
|
||||
|
||||
// Switching back never repeats setup for a backend that already finished.
|
||||
const yomitanAgain = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const restored = await yomitanAgain.ensureSetupStateInitialized();
|
||||
assert.equal(restored.state.status, 'completed');
|
||||
assert.equal(restored.state.dictionaryBackend, 'yomitan');
|
||||
assert.equal(yomitanAgain.isSetupCompleted(), true);
|
||||
assert.equal(readSetupState(getSetupStatePath(configDir))?.dictionaryBackend, 'yomitan');
|
||||
assert.deepEqual(restored.state.completedDictionaryBackends, ['hachidori', 'yomitan']);
|
||||
});
|
||||
});
|
||||
|
||||
test('a legacy completed Yomitan state file survives a first Hachidori run', async () => {
|
||||
await withTempDir(async (configDir) => {
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
fs.writeFileSync(
|
||||
getSetupStatePath(configDir),
|
||||
JSON.stringify({ ...createDefaultSetupState(), status: 'completed', completedAt: 'x' }),
|
||||
);
|
||||
const hachidori = createFirstRunSetupService({
|
||||
configDir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const initial = await hachidori.ensureSetupStateInitialized();
|
||||
assert.equal(initial.state.status, 'incomplete');
|
||||
const stored = readSetupState(getSetupStatePath(configDir));
|
||||
assert.equal(stored?.dictionaryBackend, 'hachidori');
|
||||
assert.deepEqual(stored?.completedDictionaryBackends, ['yomitan']);
|
||||
const yomitan = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
|
||||
});
|
||||
});
|
||||
|
||||
test('Hachidori setup gates on the linked host instead of local dictionaries', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
fs.writeFileSync(path.join(dir, 'config.json'), '{}');
|
||||
let host: import('../../shared/hachidori-sharing').HachidoriHostStatus = {
|
||||
kind: 'connected',
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
name: 'Docker',
|
||||
dictionaryCount: 2,
|
||||
};
|
||||
const service = createFirstRunSetupService({
|
||||
configDir: dir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getHachidoriHostStatus: async () => host,
|
||||
getYomitanDictionaryCount: async () => 7,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
let snapshot = await service.getSetupStatus();
|
||||
assert.equal(snapshot.dictionaryCount, 2);
|
||||
assert.equal(snapshot.canFinish, true);
|
||||
assert.equal((await service.markSetupCompleted()).state.status, 'completed');
|
||||
host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Host offline' };
|
||||
snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.canFinish, false);
|
||||
assert.equal(snapshot.dictionaryCount, 0);
|
||||
assert.equal(snapshot.state.status, 'incomplete');
|
||||
assert.notEqual((await service.markSetupCompleted()).state.status, 'completed');
|
||||
host = {
|
||||
kind: 'connected',
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
name: 'Docker',
|
||||
dictionaryCount: 0,
|
||||
};
|
||||
assert.equal((await service.getSetupStatus()).canFinish, false);
|
||||
host = { kind: 'local' };
|
||||
assert.equal((await service.getSetupStatus()).dictionaryCount, 7);
|
||||
assert.equal((await service.getSetupStatus()).canFinish, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from 'node:fs';
|
||||
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
|
||||
import {
|
||||
createDefaultSetupState,
|
||||
getDefaultConfigFilePaths,
|
||||
getSetupStateDictionaryBackend,
|
||||
hasCompletedSetupForBackend,
|
||||
getSetupStatePath,
|
||||
isSetupCompleted,
|
||||
readSetupState,
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
type SetupState,
|
||||
} from '../../shared/setup-state';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
import type {
|
||||
InstalledFirstRunPluginCandidate,
|
||||
LegacyMpvPluginRemovalResult,
|
||||
@@ -28,6 +32,8 @@ export interface SetupWindowsMpvShortcutSnapshot {
|
||||
}
|
||||
|
||||
export interface SetupStatusSnapshot {
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
@@ -72,6 +78,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.launchMpv ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -205,6 +212,8 @@ function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnap
|
||||
}
|
||||
|
||||
export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
@@ -213,8 +222,18 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
if (!snapshot.configReady) {
|
||||
return 'Create or provide the config file before finishing setup.';
|
||||
}
|
||||
if (
|
||||
snapshot.hachidoriHost?.kind === 'disconnected' ||
|
||||
snapshot.hachidoriHost?.kind === 'unavailable'
|
||||
) {
|
||||
return snapshot.hachidoriHost.message;
|
||||
}
|
||||
if (snapshot.hachidoriHost?.kind === 'connected' && snapshot.dictionaryCount < 1) {
|
||||
return 'Install at least one dictionary on the linked Hachidori host, then refresh status.';
|
||||
}
|
||||
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
|
||||
return 'Install at least one Yomitan dictionary before finishing setup.';
|
||||
const name = snapshot.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
|
||||
return `Install at least one ${name} dictionary before finishing setup.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -222,15 +241,38 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
async function resolveYomitanSetupStatus(deps: {
|
||||
configFilePaths: { jsoncPath: string; jsonPath: string };
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
getDictionaryBackend?: () => DictionaryBackend;
|
||||
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
}): Promise<{
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
}> {
|
||||
const configReady =
|
||||
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
|
||||
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
|
||||
const externalYomitanConfigured =
|
||||
deps.getDictionaryBackend?.() !== 'hachidori' &&
|
||||
(deps.isExternalYomitanConfigured?.() ?? false);
|
||||
|
||||
const hachidoriHost =
|
||||
deps.getDictionaryBackend?.() === 'hachidori'
|
||||
? await deps.getHachidoriHostStatus?.().catch(
|
||||
(error: unknown): HachidoriHostStatus => ({
|
||||
kind: 'unavailable',
|
||||
message: error instanceof Error ? error.message : 'Hachidori is unavailable.',
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
if (hachidoriHost && hachidoriHost.kind !== 'local') {
|
||||
return {
|
||||
configReady,
|
||||
externalYomitanConfigured: false,
|
||||
hachidoriHost,
|
||||
dictionaryCount: hachidoriHost.kind === 'connected' ? hachidoriHost.dictionaryCount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (configReady && externalYomitanConfigured) {
|
||||
return {
|
||||
@@ -242,6 +284,7 @@ async function resolveYomitanSetupStatus(deps: {
|
||||
|
||||
return {
|
||||
configReady,
|
||||
hachidoriHost,
|
||||
dictionaryCount: await deps.getYomitanDictionaryCount(),
|
||||
externalYomitanConfigured,
|
||||
};
|
||||
@@ -251,6 +294,8 @@ export function createFirstRunSetupService(deps: {
|
||||
platform?: NodeJS.Platform;
|
||||
configDir: string;
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
getDictionaryBackend?: () => DictionaryBackend;
|
||||
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||
detectLegacyMpvPluginCandidates?: () =>
|
||||
@@ -283,8 +328,44 @@ export function createFirstRunSetupService(deps: {
|
||||
const isWindows = (deps.platform ?? process.platform) === 'win32';
|
||||
let completed = false;
|
||||
|
||||
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
|
||||
const getDictionaryBackend = () => deps.getDictionaryBackend?.() ?? 'yomitan';
|
||||
const readStoredState = (): SetupState =>
|
||||
readSetupState(setupStatePath) ?? createDefaultSetupState();
|
||||
// The file records one backend's status at a time. Project it onto the active
|
||||
// backend: a backend that finished before stays completed, any other stays
|
||||
// incomplete until its own dictionaries are ready.
|
||||
const projectState = (stored: SetupState): SetupState => {
|
||||
const backend = getDictionaryBackend();
|
||||
if (getSetupStateDictionaryBackend(stored) === backend) return stored;
|
||||
const finishedBefore = hasCompletedSetupForBackend(stored, backend);
|
||||
// Legacy files carry their completion only as the recorded status; keep it.
|
||||
const storedBackend = getSetupStateDictionaryBackend(stored);
|
||||
const completedDictionaryBackends = [
|
||||
...new Set([
|
||||
...(stored.completedDictionaryBackends ?? []),
|
||||
...(stored.status === 'completed' ? [storedBackend] : []),
|
||||
]),
|
||||
];
|
||||
return {
|
||||
...stored,
|
||||
dictionaryBackend: backend,
|
||||
completedDictionaryBackends,
|
||||
status: finishedBefore ? 'completed' : 'incomplete',
|
||||
completedAt: finishedBefore ? stored.completedAt : null,
|
||||
completionSource: finishedBefore ? (stored.completionSource ?? 'user') : null,
|
||||
yomitanSetupMode: finishedBefore ? 'internal' : null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
};
|
||||
};
|
||||
const readState = (): SetupState => projectState(readStoredState());
|
||||
const writeState = (state: SetupState): SetupState => {
|
||||
const backend = getDictionaryBackend();
|
||||
const others = (state.completedDictionaryBackends ?? []).filter((entry) => entry !== backend);
|
||||
state = {
|
||||
...state,
|
||||
dictionaryBackend: backend,
|
||||
completedDictionaryBackends: state.status === 'completed' ? [...others, backend] : others,
|
||||
};
|
||||
writeSetupState(setupStatePath, state);
|
||||
completed = state.status === 'completed';
|
||||
deps.onStateChanged?.(state);
|
||||
@@ -292,10 +373,12 @@ export function createFirstRunSetupService(deps: {
|
||||
};
|
||||
|
||||
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured } =
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured, hachidoriHost } =
|
||||
await resolveYomitanSetupStatus({
|
||||
configFilePaths,
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
getDictionaryBackend,
|
||||
getHachidoriHostStatus: deps.getHachidoriHostStatus,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const pluginInstalled = await deps.detectPluginInstalled();
|
||||
@@ -314,6 +397,8 @@ export function createFirstRunSetupService(deps: {
|
||||
installedWindowsMpvShortcuts,
|
||||
);
|
||||
return {
|
||||
dictionaryBackend: getDictionaryBackend(),
|
||||
hachidoriHost,
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
canFinish: isYomitanSetupSatisfied({
|
||||
@@ -353,11 +438,19 @@ export function createFirstRunSetupService(deps: {
|
||||
|
||||
return {
|
||||
ensureSetupStateInitialized: async () => {
|
||||
const state = readState();
|
||||
const stored = readStoredState();
|
||||
// Persist the active backend stamp so the launcher can tell which backend
|
||||
// the running app gates playback on.
|
||||
const state =
|
||||
getSetupStateDictionaryBackend(stored) === getDictionaryBackend()
|
||||
? stored
|
||||
: writeState(projectState(stored));
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured } =
|
||||
await resolveYomitanSetupStatus({
|
||||
configFilePaths,
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
getDictionaryBackend,
|
||||
getHachidoriHostStatus: deps.getHachidoriHostStatus,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const canFinish = isYomitanSetupSatisfied({
|
||||
|
||||
@@ -779,3 +779,51 @@ test('closing completed first-run setup quits app when completion policy allows
|
||||
|
||||
assert.deepEqual(calls, ['set', 'clear', 'quit']);
|
||||
});
|
||||
|
||||
test('Hachidori setup names the active dictionary backend', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
dictionaryBackend: 'hachidori',
|
||||
configReady: true,
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
});
|
||||
assert.match(html, /Hachidori dictionaries/);
|
||||
assert.match(html, /Open Hachidori Settings/);
|
||||
assert.match(html, /Install at least one Hachidori dictionary/);
|
||||
assert.doesNotMatch(html, /Open Yomitan Settings/);
|
||||
assert.match(html, /Link host/);
|
||||
assert.match(html, /<details class="external-host">/);
|
||||
assert.match(html, /Use an external dictionary host/);
|
||||
assert.match(html, /keep the browser and its sharing relay running/);
|
||||
assert.match(html, /You can close its browser management page/);
|
||||
});
|
||||
|
||||
test('setup parses link and unlink actions without losing the host address', () => {
|
||||
const address = 'ws://127.0.0.1:38771/link';
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl(
|
||||
'subminer://first-run-setup?action=link-hachidori-host&address=' +
|
||||
encodeURIComponent(address),
|
||||
),
|
||||
{ action: 'link-hachidori-host', address },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=unlink-hachidori-host'),
|
||||
{ action: 'unlink-hachidori-host' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
|
||||
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
|
||||
|
||||
@@ -18,25 +20,31 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export type FirstRunSetupAction =
|
||||
| 'configure-mpv-executable-path'
|
||||
| 'remove-legacy-plugin'
|
||||
| 'configure-windows-mpv-shortcuts'
|
||||
| 'install-bun'
|
||||
| 'install-command-line-launcher'
|
||||
| 'open-yomitan-settings'
|
||||
| 'open-config-settings'
|
||||
| 'refresh'
|
||||
| 'finish';
|
||||
export type FirstRunSetupSubmission =
|
||||
| { action: 'configure-mpv-executable-path'; mpvExecutablePath: string }
|
||||
| {
|
||||
action: 'configure-windows-mpv-shortcuts';
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
}
|
||||
| { action: 'link-hachidori-host'; address: string }
|
||||
| {
|
||||
action:
|
||||
| 'unlink-hachidori-host'
|
||||
| 'remove-legacy-plugin'
|
||||
| 'install-bun'
|
||||
| 'install-command-line-launcher'
|
||||
| 'open-yomitan-settings'
|
||||
| 'open-config-settings'
|
||||
| 'refresh'
|
||||
| 'finish';
|
||||
};
|
||||
|
||||
export interface FirstRunSetupSubmission {
|
||||
action: FirstRunSetupAction;
|
||||
mpvExecutablePath?: string;
|
||||
startMenuEnabled?: boolean;
|
||||
desktopEnabled?: boolean;
|
||||
}
|
||||
export type FirstRunSetupAction = FirstRunSetupSubmission['action'];
|
||||
|
||||
export interface FirstRunSetupHtmlModel {
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
@@ -249,9 +257,47 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const yomitanMeta = model.externalYomitanConfigured
|
||||
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
|
||||
: `${model.dictionaryCount} installed`;
|
||||
const dictionaryName = model.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
|
||||
const host = model.hachidoriHost;
|
||||
const linked = host?.kind === 'connected' || host?.kind === 'disconnected';
|
||||
const hostAddress = linked ? host.address : '';
|
||||
const hostCard =
|
||||
model.dictionaryBackend === 'hachidori'
|
||||
? `
|
||||
<div class="card block">
|
||||
<strong>Dictionary source</strong>
|
||||
<p class="meta">Import dictionaries through Hachidori Settings to keep them in SubMiner, or connect to an existing library below.</p>
|
||||
<details class="external-host"${linked ? ' open' : ''}>
|
||||
<summary>Use an external dictionary host</summary>
|
||||
<p class="meta">Connect to Hachidori in another app, browser, or Docker container to use the dictionaries already installed there. You won't need to import a second copy into SubMiner.</p>
|
||||
<p class="meta">Dictionaries and their settings are shared. You still mine cards in SubMiner, using its own Anki settings, audio, and screenshots.</p>
|
||||
<ul class="meta host-requirements">
|
||||
<li>Browser: keep the browser and its sharing relay running. If the relay runs through Anki, keep Anki open too.</li>
|
||||
<li>Desktop app: keep the app sharing your dictionaries and any required relay running.</li>
|
||||
<li>Docker: keep the dictionary container running. You can close its browser management page.</li>
|
||||
</ul>
|
||||
<p class="meta">If that app or container stops or loses its connection, dictionary lookups will be unavailable until it reconnects.</p>
|
||||
<form class="path-form" onsubmit="event.preventDefault(); const address = document.getElementById('hachidori-host-address').value; window.location.href='subminer://first-run-setup?action=link-hachidori-host&address='+encodeURIComponent(address)">
|
||||
<label for="hachidori-host-address">Host address</label>
|
||||
<input id="hachidori-host-address" type="text" value="${escapeHtml(hostAddress)}" placeholder="127.0.0.1:8771 or ws://host:8771/link" required />
|
||||
<div class="meta">Use the address from Hachidori's Sharing settings or your container's WebSocket sharing address, rather than its management page URL.</div>
|
||||
<div class="inline-actions">
|
||||
<button type="submit">${linked ? 'Change host' : 'Link host'}</button>
|
||||
${linked ? `<button type="button" class="ghost" onclick="window.location.href='subminer://first-run-setup?action=unlink-hachidori-host'">Unlink and use local dictionaries</button>` : ''}
|
||||
</div>
|
||||
</form>
|
||||
${host?.kind === 'disconnected' || host?.kind === 'unavailable' ? `<p class="meta">${escapeHtml(host.message)}</p>` : ''}
|
||||
</details>
|
||||
</div>`
|
||||
: '';
|
||||
const yomitanMeta =
|
||||
host?.kind === 'connected'
|
||||
? `${host.dictionaryCount} ${host.dictionaryCount === 1 ? 'dictionary' : 'dictionaries'} from ${host.name} at ${host.address}`
|
||||
: host?.kind === 'disconnected'
|
||||
? `Host unavailable: ${host.address}`
|
||||
: model.externalYomitanConfigured
|
||||
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
|
||||
: `${model.dictionaryCount} installed`;
|
||||
const yomitanBadgeLabel = model.externalYomitanConfigured
|
||||
? 'External'
|
||||
: model.dictionaryCount >= 1
|
||||
@@ -268,8 +314,8 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
: model.canFinish
|
||||
? model.externalYomitanConfigured
|
||||
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
||||
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
|
||||
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
||||
: `Finish stays unlocked once ${dictionaryName} reports at least one installed dictionary.`
|
||||
: `Finish stays locked until ${dictionaryName} reports at least one installed dictionary.`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -338,6 +384,21 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.external-host summary {
|
||||
cursor: pointer;
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
.external-host[open] summary {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.host-requirements {
|
||||
padding-left: 20px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.host-requirements li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.shortcut-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -466,17 +527,18 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>Yomitan dictionaries</strong>
|
||||
<strong>${dictionaryName} dictionaries</strong>
|
||||
<div class="meta">${escapeHtml(yomitanMeta)}</div>
|
||||
</div>
|
||||
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
|
||||
</div>
|
||||
${hostCard}
|
||||
${mpvExecutablePathCard}
|
||||
${windowsShortcutCard}
|
||||
${renderCommandLineLauncherSection(model.commandLineLauncher)}
|
||||
${legacyPluginCard}
|
||||
<div class="actions">
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open ${dictionaryName} Settings</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
|
||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
|
||||
@@ -495,6 +557,8 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
const parsed = new URL(rawUrl);
|
||||
const action = parsed.searchParams.get('action');
|
||||
if (
|
||||
action !== 'link-hachidori-host' &&
|
||||
action !== 'unlink-hachidori-host' &&
|
||||
action !== 'configure-mpv-executable-path' &&
|
||||
action !== 'remove-legacy-plugin' &&
|
||||
action !== 'configure-windows-mpv-shortcuts' &&
|
||||
@@ -507,6 +571,9 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (action === 'link-hachidori-host') {
|
||||
return { action, address: parsed.searchParams.get('address')?.trim() ?? '' };
|
||||
}
|
||||
if (action === 'configure-mpv-executable-path') {
|
||||
return {
|
||||
action,
|
||||
|
||||
@@ -37,3 +37,10 @@ test('managed background playback handles initial args before deferred overlay w
|
||||
);
|
||||
assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(null), false);
|
||||
});
|
||||
|
||||
for (const flag of ['--yomitan', '--hachidori']) {
|
||||
test(`${flag} settings startup skips heavy startup`, () => {
|
||||
assert.equal(getStartupModeFlags(parseArgs([flag])).shouldSkipHeavyStartup, true);
|
||||
assert.equal(getStartupModeFlags(parseArgs([flag, '--start'])).shouldSkipHeavyStartup, false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args';
|
||||
import {
|
||||
isHeadlessInitialCommand,
|
||||
isStandaloneTexthookerCommand,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldRunDictionarySettingsOnlyStartup,
|
||||
} from '../../cli/args';
|
||||
|
||||
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
@@ -20,7 +20,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunYomitanOnlyStartup(initialArgs) ||
|
||||
(shouldRunDictionarySettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.settings ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
|
||||
@@ -172,6 +172,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||
deck: ankiConnectConfig.deck,
|
||||
ankiConfig: ankiConnectConfig,
|
||||
});
|
||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||
|
||||
@@ -71,6 +71,8 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||
@@ -127,6 +129,8 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
||||
showFirstRunSetup: () => false,
|
||||
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('configuration'),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
|
||||
export function createResolveTrayIconPathHandler(deps: {
|
||||
resolveTrayIconPathRuntime: (options: {
|
||||
platform: string;
|
||||
@@ -46,6 +48,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
@@ -67,6 +71,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
getDictionaryBackend: () => DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
@@ -107,6 +113,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
deps.openFirstRunSetupWindow(true);
|
||||
},
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
|
||||
dictionaryBackend: deps.getDictionaryBackend(),
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openYomitanSettings: () => {
|
||||
deps.openYomitanSettings();
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||
@@ -58,6 +60,8 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettings: () => calls.push('open-configuration'),
|
||||
openSyncUi: () => {},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
|
||||
export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
||||
resolveTrayIconPathRuntime: (options: {
|
||||
platform: string;
|
||||
@@ -36,6 +38,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
@@ -57,6 +61,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
getDictionaryBackend: () => DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
@@ -82,6 +88,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
getDictionaryBackend: deps.getDictionaryBackend,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
|
||||
@@ -31,6 +31,8 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -26,85 +26,96 @@ test('resolve tray icon returns null when no asset exists', () => {
|
||||
assert.equal(path, null);
|
||||
});
|
||||
|
||||
test('tray menu template contains expected entries and handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openChangelog: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettings: () => calls.push('configuration'),
|
||||
openSyncUi: () => calls.push('sync-ui'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
|
||||
const settingsLabel =
|
||||
dictionaryBackend === 'hachidori' ? 'Open Hachidori Settings' : 'Open Yomitan Settings';
|
||||
test(`tray menu shows only ${dictionaryBackend} settings and dispatches its handler`, () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openChangelog: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
dictionaryBackend,
|
||||
openHachidoriSettings: () => calls.push('hachidori'),
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettings: () => calls.push('configuration'),
|
||||
openSyncUi: () => calls.push('sync-ui'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
// Resolve by label, not index: adding a menu entry should not force every
|
||||
// later assertion in this test to be renumbered.
|
||||
const entryFor = (label: string) => {
|
||||
const entry = template.find((candidate) => candidate.label === label);
|
||||
assert.ok(entry, `expected a "${label}" tray entry`);
|
||||
return entry;
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
template.map((entry) => entry.label ?? `<${entry.type}>`),
|
||||
[
|
||||
'Open Help',
|
||||
'View Changelog',
|
||||
'Open Texthooker',
|
||||
'Complete Setup',
|
||||
'Open SubMiner Setup',
|
||||
settingsLabel,
|
||||
'Open SubMiner Settings',
|
||||
'Sync Stats && History',
|
||||
'Export Logs',
|
||||
'Configure Jellyfin',
|
||||
'Jellyfin Discovery',
|
||||
'Configure AniList',
|
||||
'Check for Updates',
|
||||
'<separator>',
|
||||
'Quit',
|
||||
],
|
||||
);
|
||||
|
||||
entryFor(settingsLabel).click?.();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0], dictionaryBackend);
|
||||
calls.length = 0;
|
||||
|
||||
const discovery = entryFor('Jellyfin Discovery');
|
||||
assert.equal(discovery.type, 'checkbox');
|
||||
assert.equal(discovery.checked, false);
|
||||
discovery.click?.({ checked: true });
|
||||
|
||||
entryFor('Open Help').click?.();
|
||||
entryFor('View Changelog').click?.();
|
||||
entryFor('Open Texthooker').click?.();
|
||||
entryFor('Sync Stats && History').click?.();
|
||||
entryFor('Export Logs').click?.();
|
||||
entryFor('Check for Updates').click?.();
|
||||
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||
entryFor('Quit').click?.();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery:true',
|
||||
'help',
|
||||
'changelog',
|
||||
'texthooker',
|
||||
'sync-ui',
|
||||
'export-logs',
|
||||
'updates',
|
||||
'separator',
|
||||
'quit',
|
||||
]);
|
||||
});
|
||||
|
||||
// Resolve by label, not index: adding a menu entry should not force every
|
||||
// later assertion in this test to be renumbered.
|
||||
const entryFor = (label: string) => {
|
||||
const entry = template.find((candidate) => candidate.label === label);
|
||||
assert.ok(entry, `expected a "${label}" tray entry`);
|
||||
return entry;
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
template.map((entry) => entry.label ?? `<${entry.type}>`),
|
||||
[
|
||||
'Open Help',
|
||||
'View Changelog',
|
||||
'Open Texthooker',
|
||||
'Complete Setup',
|
||||
'Open SubMiner Setup',
|
||||
'Open Yomitan Settings',
|
||||
'Open SubMiner Settings',
|
||||
'Sync Stats && History',
|
||||
'Export Logs',
|
||||
'Configure Jellyfin',
|
||||
'Jellyfin Discovery',
|
||||
'Configure AniList',
|
||||
'Check for Updates',
|
||||
'<separator>',
|
||||
'Quit',
|
||||
],
|
||||
);
|
||||
|
||||
const discovery = entryFor('Jellyfin Discovery');
|
||||
assert.equal(discovery.type, 'checkbox');
|
||||
assert.equal(discovery.checked, false);
|
||||
discovery.click?.({ checked: true });
|
||||
|
||||
entryFor('Open Help').click?.();
|
||||
entryFor('View Changelog').click?.();
|
||||
entryFor('Open Texthooker').click?.();
|
||||
entryFor('Sync Stats && History').click?.();
|
||||
entryFor('Export Logs').click?.();
|
||||
entryFor('Check for Updates').click?.();
|
||||
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||
entryFor('Quit').click?.();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery:true',
|
||||
'help',
|
||||
'changelog',
|
||||
'texthooker',
|
||||
'sync-ui',
|
||||
'export-logs',
|
||||
'updates',
|
||||
'separator',
|
||||
'quit',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
@@ -116,6 +127,8 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
@@ -146,6 +159,8 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
@@ -174,6 +189,8 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
@@ -203,6 +220,8 @@ test('tray menu template renders a visible linux discovery check mark when activ
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
|
||||
export function resolveTrayIconPathRuntime(deps: {
|
||||
platform: string;
|
||||
resourcesPath: string;
|
||||
@@ -39,6 +41,8 @@ export type TrayMenuActionHandlers = {
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
@@ -102,8 +106,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Open Yomitan Settings',
|
||||
click: handlers.openYomitanSettings,
|
||||
label:
|
||||
handlers.dictionaryBackend === 'hachidori'
|
||||
? 'Open Hachidori Settings'
|
||||
: 'Open Yomitan Settings',
|
||||
click:
|
||||
handlers.dictionaryBackend === 'hachidori'
|
||||
? handlers.openHachidoriSettings
|
||||
: handlers.openYomitanSettings,
|
||||
},
|
||||
{
|
||||
label: 'Open SubMiner Settings',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync';
|
||||
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
|
||||
|
||||
test('buildYomitanAnkiSettingsKey includes force override policy', () => {
|
||||
assert.notEqual(
|
||||
@@ -16,3 +17,15 @@ test('buildYomitanAnkiSettingsKey includes force override policy', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('settings sync key changes when fields or tags change', () => {
|
||||
const key = (word: string, tags: string[]) =>
|
||||
buildYomitanAnkiSettingsKey({
|
||||
targetUrl: 'http://127.0.0.1:8766',
|
||||
targetDeck: 'Mining',
|
||||
forceOverride: true,
|
||||
hachidoriHints: buildHachidoriAnkiHints({ fields: { word }, tags }),
|
||||
});
|
||||
assert.notEqual(key('Word', ['SubMiner']), key('Expression', ['SubMiner']));
|
||||
assert.notEqual(key('Word', ['SubMiner']), key('Word', ['Japanese']));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
@@ -17,8 +18,9 @@ export function buildYomitanAnkiSettingsKey(options: {
|
||||
targetUrl: string;
|
||||
targetDeck: string;
|
||||
forceOverride: boolean;
|
||||
hachidoriHints?: ReturnType<typeof buildHachidoriAnkiHints>;
|
||||
}): string {
|
||||
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`;
|
||||
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}\n${JSON.stringify(options.hachidoriHints)}`;
|
||||
}
|
||||
|
||||
export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): {
|
||||
@@ -45,6 +47,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
|
||||
targetUrl,
|
||||
targetDeck,
|
||||
forceOverride,
|
||||
hachidoriHints: buildHachidoriAnkiHints(ankiConnectConfig),
|
||||
});
|
||||
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
|
||||
return;
|
||||
@@ -64,6 +67,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
|
||||
{
|
||||
forceOverride,
|
||||
deck: targetDeck,
|
||||
ankiConfig: ankiConnectConfig,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ test('sidebar clipboard bridge writes exact text without renderer focus and reje
|
||||
let exposed: unknown;
|
||||
runInNewContext(output.text, {
|
||||
process: { argv: [] },
|
||||
window: { addEventListener: () => {} },
|
||||
require: (name: string) => {
|
||||
assert.equal(name, 'electron');
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,44 @@
|
||||
|
||||
import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
|
||||
import { resolveOverlayLayerFromArgv } from './preload-args';
|
||||
import {
|
||||
DICTIONARY_EXTERNAL_LINK_CHANNEL,
|
||||
parseDictionaryExternalUrl,
|
||||
} from './shared/dictionary-external-link';
|
||||
|
||||
window.addEventListener('hachidori-open-external', (event) => {
|
||||
if (!(event instanceof CustomEvent)) return;
|
||||
const detail: unknown = event.detail;
|
||||
if (
|
||||
!detail ||
|
||||
typeof detail !== 'object' ||
|
||||
!('requestId' in detail) ||
|
||||
typeof detail.requestId !== 'string' ||
|
||||
!('url' in detail)
|
||||
)
|
||||
return;
|
||||
const requestId = detail.requestId;
|
||||
void Promise.resolve()
|
||||
.then(() =>
|
||||
ipcRenderer.invoke(DICTIONARY_EXTERNAL_LINK_CHANNEL, parseDictionaryExternalUrl(detail.url)),
|
||||
)
|
||||
.then(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('hachidori-open-external-result', { detail: { requestId, ok: true } }),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('hachidori-open-external-result', {
|
||||
detail: {
|
||||
requestId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
import type {
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
|
||||
@@ -368,7 +368,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] {
|
||||
{ shortcut: 'Y then S', action: 'Start overlay' },
|
||||
{ shortcut: 'Y then Shift + S', action: 'Stop overlay' },
|
||||
{ shortcut: 'Y then T', action: 'Toggle visible overlay' },
|
||||
{ shortcut: 'Y then O', action: 'Open Yomitan settings' },
|
||||
{ shortcut: 'Y then O', action: 'Open dictionary settings' },
|
||||
{ shortcut: 'Y then R', action: 'Restart overlay' },
|
||||
{ shortcut: 'Y then C', action: 'Check overlay status' },
|
||||
{ shortcut: 'Y then H/K', action: 'Open session help' },
|
||||
@@ -377,7 +377,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] {
|
||||
},
|
||||
{
|
||||
title: 'Global shortcuts',
|
||||
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open Yomitan settings' }],
|
||||
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open dictionary settings' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
|
||||
assert.equal(state.youtubePickerModalOpen, false);
|
||||
assert.deepEqual(syncCalls, ['sync', 'sync', 'restore-pointer']);
|
||||
assert.deepEqual(notifications, ['youtube-track-picker']);
|
||||
assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]);
|
||||
assert.deepEqual(frontendCommands, []);
|
||||
assert.equal(overlay.classList.contains('interactive'), false);
|
||||
assert.equal(focusMainWindowCalls.length > 0, true);
|
||||
assert.equal(overlayFocusCalls.length > 0, true);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { YoutubePickerOpenPayload } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
||||
|
||||
function createOption(value: string, label: string): HTMLOptionElement {
|
||||
const option = document.createElement('option');
|
||||
@@ -197,13 +196,6 @@ export function createYoutubeTrackPickerModal(
|
||||
ctx.dom.youtubePickerModal.classList.add('hidden');
|
||||
ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('youtube-track-picker');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'refreshOptions',
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
@@ -230,12 +230,12 @@ async function loadYomitanAnkiDeckName(): Promise<void> {
|
||||
state.yomitanAnkiDeckNameError = null;
|
||||
} else {
|
||||
state.yomitanAnkiDeckName = '';
|
||||
state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read Yomitan Anki deck.';
|
||||
state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read the dictionary Anki deck.';
|
||||
}
|
||||
} catch (error) {
|
||||
state.yomitanAnkiDeckName = '';
|
||||
state.yomitanAnkiDeckNameError =
|
||||
error instanceof Error ? error.message : 'Failed to read Yomitan Anki deck.';
|
||||
error instanceof Error ? error.message : 'Failed to read the dictionary Anki deck.';
|
||||
} finally {
|
||||
state.yomitanAnkiDeckNameLoading = false;
|
||||
requestRender();
|
||||
|
||||
@@ -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,160 @@
|
||||
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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(requests[0]?.address, address);
|
||||
assert.deepEqual(parseHachidoriHostStatus(value), {
|
||||
kind: 'connected',
|
||||
address,
|
||||
name: 'Host',
|
||||
dictionaryCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
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,92 @@
|
||||
export type HachidoriHostStatus =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'connected'; address: string; name: string; dictionaryCount: number }
|
||||
| { kind: 'disconnected'; address: string; message: string }
|
||||
| { kind: 'unavailable'; message: string };
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -333,3 +334,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?: {
|
||||
|
||||
+46
-23
@@ -5,6 +5,14 @@ import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import { ConfigService } from './config/service';
|
||||
import { createLogger, setLogLevel } from './logger';
|
||||
import { loadYomitanExtension } from './core/services/yomitan-extension-loader';
|
||||
import {
|
||||
createHachidoriExtensionRuntime,
|
||||
getHachidoriSession,
|
||||
} from './core/services/hachidori-extension';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
} from './main/runtime/yomitan-anki-server';
|
||||
import {
|
||||
addYomitanNoteViaSearch,
|
||||
getYomitanCurrentAnkiDeckName,
|
||||
@@ -104,27 +112,38 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
const configService = new ConfigService(userDataPath!);
|
||||
const config = configService.getConfig();
|
||||
const extension = await loadYomitanExtension({
|
||||
userDataPath: userDataPath!,
|
||||
getYomitanParserWindow: () => yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
yomitanParserWindow = window;
|
||||
},
|
||||
setYomitanParserReadyPromise: (promise) => {
|
||||
yomitanParserReadyPromise = promise;
|
||||
},
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
yomitanParserInitPromise = promise;
|
||||
},
|
||||
setYomitanExtension: (extensionValue) => {
|
||||
yomitanExt = extensionValue;
|
||||
},
|
||||
setYomitanSession: (sessionValue) => {
|
||||
yomitanSession = sessionValue;
|
||||
},
|
||||
});
|
||||
// Mine with the same backend the app uses so notes come from the user's
|
||||
// dictionaries and Anki templates, not an empty profile.
|
||||
const extension =
|
||||
config.dictionaryBackend === 'hachidori'
|
||||
? await createHachidoriExtensionRuntime(userDataPath!)
|
||||
.ensureLoaded()
|
||||
.then((loaded) => {
|
||||
yomitanExt = loaded;
|
||||
yomitanSession = getHachidoriSession();
|
||||
return loaded;
|
||||
})
|
||||
: await loadYomitanExtension({
|
||||
userDataPath: userDataPath!,
|
||||
getYomitanParserWindow: () => yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
yomitanParserWindow = window;
|
||||
},
|
||||
setYomitanParserReadyPromise: (promise) => {
|
||||
yomitanParserReadyPromise = promise;
|
||||
},
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
yomitanParserInitPromise = promise;
|
||||
},
|
||||
setYomitanExtension: (extensionValue) => {
|
||||
yomitanExt = extensionValue;
|
||||
},
|
||||
setYomitanSession: (sessionValue) => {
|
||||
yomitanSession = sessionValue;
|
||||
},
|
||||
});
|
||||
if (!extension) {
|
||||
throw new Error('Yomitan extension failed to load.');
|
||||
throw new Error(`${config.dictionaryBackend} extension failed to load.`);
|
||||
}
|
||||
|
||||
const yomitanDeps = {
|
||||
@@ -156,17 +175,21 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await syncYomitanDefaultAnkiServer(
|
||||
config.ankiConnect?.url || 'http://127.0.0.1:8765',
|
||||
getPreferredYomitanAnkiServerUrl(config.ankiConnect),
|
||||
yomitanDeps,
|
||||
logger,
|
||||
{ forceOverride: true, deck: config.ankiConnect?.deck },
|
||||
{
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(config.ankiConnect),
|
||||
deck: config.ankiConnect?.deck,
|
||||
ankiConfig: config.ankiConnect,
|
||||
},
|
||||
);
|
||||
|
||||
const addResult = await addYomitanNoteViaSearch(word!, yomitanDeps, logger);
|
||||
|
||||
const noteId = addResult.noteId;
|
||||
if (typeof noteId !== 'number') {
|
||||
throw new Error('Yomitan failed to create note.');
|
||||
throw new Error(`${config.dictionaryBackend} failed to create note.`);
|
||||
}
|
||||
|
||||
writeResponse(responsePath, {
|
||||
|
||||
@@ -140,7 +140,10 @@ export interface RawShortcutsConfig extends ShortcutsConfig {
|
||||
openAnimetosho?: string | null;
|
||||
}
|
||||
|
||||
export type DictionaryBackend = 'yomitan' | 'hachidori';
|
||||
|
||||
export interface Config {
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
subtitlePosition?: SubtitlePosition;
|
||||
keybindings?: Keybinding[];
|
||||
websocket?: WebSocketConfig;
|
||||
@@ -183,6 +186,7 @@ export interface Config {
|
||||
export type RawConfig = Config;
|
||||
|
||||
export interface ResolvedConfig {
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
subtitlePosition: SubtitlePosition;
|
||||
keybindings: Keybinding[];
|
||||
websocket: Required<WebSocketConfig>;
|
||||
|
||||
Reference in New Issue
Block a user