mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
feat(dictionary): add Hachidori backend support (#266)
This commit is contained in:
1 parent
e690ffba07
commit
cbaa4626e1
143 files changed
+5564
-305
No files matched your search
@@ -1802,6 +1802,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,127 @@ 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('stats-owned notes bypass overlay enrichment while popup notes still enqueue', async () => {
|
||||
const processed: number[] = [];
|
||||
const added: number[] = [];
|
||||
const received: unknown[] = [];
|
||||
let noteId = 70;
|
||||
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.end(JSON.stringify({ result: ++noteId, error: null }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const address = upstream.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (id) => {
|
||||
processed.push(id);
|
||||
},
|
||||
recordCardsAdded: (_count, ids) => {
|
||||
added.push(...ids);
|
||||
},
|
||||
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 metadata of [{ subminerEnrich: false }, {}]) {
|
||||
const response: Response = await fetch(`http://127.0.0.1:${bound.port}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'addNote',
|
||||
version: 6,
|
||||
params: { note: { fields: { Expression: '猫' } }, ...metadata },
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
await response.json();
|
||||
}
|
||||
await waitForCondition(() => processed.includes(72));
|
||||
assert.deepEqual(processed, [72]);
|
||||
assert.deepEqual(added, [71, 72]);
|
||||
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;
|
||||
}
|
||||
@@ -239,12 +264,23 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
|
||||
const noteIds =
|
||||
action === 'multi'
|
||||
? this.collectMultiResultIds(requestJson, responseResult)
|
||||
: this.collectNoteIdsForAction(action, responseResult);
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
action === 'addNote' &&
|
||||
params &&
|
||||
typeof params === 'object' &&
|
||||
'subminerEnrich' in params &&
|
||||
params.subminerEnrich === false
|
||||
) {
|
||||
// Stats owns the saved sentence and media; the live mpv context is unrelated.
|
||||
if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
|
||||
return;
|
||||
}
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
|
||||
void this.enqueueMostRecentAddedNote();
|
||||
return;
|
||||
@@ -293,7 +329,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 +337,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 +496,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 +513,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 {
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import type { CardKind } from '../types/anki';
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||
|
||||
function setCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
@@ -110,6 +111,27 @@ function createWorkflowHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('NoteUpdateWorkflow preserves stats cards discovered by polling', async () => {
|
||||
const { workflow, deps, updates } = createWorkflowHarness();
|
||||
const note = {
|
||||
noteId: 42,
|
||||
tags: [STATS_MINING_TAG],
|
||||
fields: { Expression: { value: '猫' }, Sentence: { value: '猫がいる。' } },
|
||||
};
|
||||
deps.client.notesInfo = async () => [note];
|
||||
deps.captureSubtitleMediaContext = () => assert.fail('Must not capture current playback');
|
||||
deps.findDuplicateNote = async () => assert.fail('Must not regroup a stats card');
|
||||
let cachedNote: NoteUpdateWorkflowNoteInfo | undefined;
|
||||
deps.appendKnownWordsFromNoteInfo = (value) => {
|
||||
cachedNote = value;
|
||||
};
|
||||
|
||||
await workflow.execute(42);
|
||||
|
||||
assert.deepEqual(updates, []);
|
||||
assert.equal(cachedNote, note);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import type {
|
||||
WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { resolveWordCardKind } from './note-field-utils';
|
||||
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
tags?: string[];
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
@@ -185,6 +187,10 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
|
||||
const noteInfo = notesInfo[0]!;
|
||||
if (noteInfo.tags?.includes(STATS_MINING_TAG)) {
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
return;
|
||||
}
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
const config = this.deps.getConfig();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isStandaloneTexthookerCommand,
|
||||
parseArgs,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldRunDictionarySettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
} from './args';
|
||||
|
||||
@@ -476,3 +477,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
@@ -12,6 +12,7 @@ export interface CliArgs {
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
hachidori: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
youtubeBrowser: boolean;
|
||||
@@ -133,6 +134,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
@@ -279,6 +281,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 === '--youtube-browser') args.youtubeBrowser = true;
|
||||
@@ -563,6 +566,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.youtubeBrowser ||
|
||||
@@ -643,6 +647,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.hachidori &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.youtubeBrowser &&
|
||||
@@ -716,6 +721,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.youtubeBrowser ||
|
||||
@@ -767,8 +773,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 &&
|
||||
|
||||
@@ -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 = {
|
||||
subtitleSelection: { enabled: false },
|
||||
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: 'Subtitle Selection',
|
||||
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
|
||||
|
||||
@@ -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.subtitleSelection)) {
|
||||
const enabled = asBoolean(src.subtitleSelection.enabled);
|
||||
if (enabled !== undefined) resolved.subtitleSelection.enabled = enabled;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -146,6 +146,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Discord Rich Presence',
|
||||
'Jellyfin',
|
||||
'Texthooker',
|
||||
'Dictionary Lookup',
|
||||
'Yomitan',
|
||||
'Stats dashboard',
|
||||
'Startup warmups',
|
||||
@@ -228,8 +229,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',
|
||||
@@ -270,7 +271,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':
|
||||
@@ -334,6 +335,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' ||
|
||||
|
||||
@@ -1831,6 +1831,7 @@ describe('stats server API routes', () => {
|
||||
});
|
||||
|
||||
assert.equal(status, 200, JSON.stringify(body));
|
||||
assert.deepEqual(sentNote(env.requests, 'addNote').tags, ['SubMiner', 'SubMiner::Stats']);
|
||||
assert.match(
|
||||
sentNote(env.requests, 'updateNoteFields').fields.Picture ?? '',
|
||||
/^<img src="subminer_image_\d+_12345\.jpg">$/,
|
||||
|
||||
@@ -6,6 +6,7 @@ import test from 'node:test';
|
||||
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
|
||||
import { Database } from '../immersion-tracker/sqlite.js';
|
||||
import {
|
||||
applyPragmas,
|
||||
ensureSchema,
|
||||
getOrCreateAnimeRecord,
|
||||
getOrCreateVideoRecord,
|
||||
@@ -50,6 +51,7 @@ test('stripFilenameTags normalizes common media-title formats', () => {
|
||||
async function backfillMissingCoverBlob(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-test.mkv', {
|
||||
canonicalTitle: 'Cover Fetcher Test',
|
||||
@@ -114,6 +116,7 @@ test(
|
||||
async function reuseCachedAnimeCoverArt(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-1.mkv', {
|
||||
canonicalTitle: 'Shared Cover Show',
|
||||
@@ -203,6 +206,7 @@ function createJsonResponse(payload: unknown): Response {
|
||||
test('fetchIfMissing uses guessit primary title and season when available', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-season-test.mkv', {
|
||||
canonicalTitle:
|
||||
@@ -308,6 +312,7 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
||||
test('fetchIfMissing falls back to internal parser when guessit throws', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-fallback-test.mkv', {
|
||||
canonicalTitle: 'School Vlog S01E01',
|
||||
@@ -372,6 +377,7 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
|
||||
test('fetchIfMissing caches a no-match when the season cannot be resolved', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-unresolved.mkv', {
|
||||
canonicalTitle: 'Unresolved Show (2013) - S03E01 - Something [1080p].mkv',
|
||||
@@ -450,6 +456,7 @@ test('fetchIfMissing caches a no-match when the season cannot be resolved', asyn
|
||||
test('fetchIfMissing re-resolves an unresolved season once AniList publishes the relation', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-recovers.mkv', {
|
||||
canonicalTitle: 'Recovering Show (2013) - S02E01 - Something [1080p].mkv',
|
||||
@@ -557,6 +564,7 @@ for (const linkedToAnilist of [false, true]) {
|
||||
test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
|
||||
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
|
||||
@@ -674,6 +682,7 @@ for (const linkedToAnilist of [false, true]) {
|
||||
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', {
|
||||
canonicalTitle: 'Hanzawa Naoki - 02.mkv',
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
@@ -129,6 +130,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
togglePrimarySubtitleBar: () => {
|
||||
calls.push('togglePrimarySubtitleBar');
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openHachidoriSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
@@ -980,3 +984,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;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -167,6 +168,7 @@ interface MiningCliRuntime {
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -273,6 +275,11 @@ export function createCliCommandDepsRuntime(
|
||||
options.ui.openYomitanSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
options.ui.openHachidoriSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: options.ui.openYoutubeBrowserWindow,
|
||||
@@ -424,6 +431,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,
|
||||
youtubeBrowser: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/ani
|
||||
import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
|
||||
import { MediaGenerator } from '../../../media-generator.js';
|
||||
import { statsJson } from '../../../types/stats-http-contract.js';
|
||||
import { STATS_MINING_TAG } from '../../../shared/anki-source.js';
|
||||
import { widenHalfwidthKana } from '../../utils/halfwidth-kana.js';
|
||||
import {
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||
@@ -372,7 +373,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
||||
}
|
||||
|
||||
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
|
||||
const tags = ankiConfig.tags ?? ['SubMiner'];
|
||||
const tags = [...new Set([...(ankiConfig.tags ?? ['SubMiner']), STATS_MINING_TAG])];
|
||||
|
||||
const addNotePromise = timeMiningPhase(
|
||||
mode,
|
||||
|
||||
@@ -300,3 +300,65 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
|
||||
);
|
||||
assert.ok(tokenizedTexts.includes('other'));
|
||||
});
|
||||
|
||||
test('prefetch service keeps an in-flight result from a superseded run', async () => {
|
||||
const cues = makeCues(5);
|
||||
const cachedTexts: string[] = [];
|
||||
let releaseFirst!: () => void;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
|
||||
const service = createSubtitlePrefetchService({
|
||||
cues,
|
||||
tokenizeSubtitle: async (text) => {
|
||||
if (text === 'line-0') await firstGate;
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: (text) => {
|
||||
cachedTexts.push(text);
|
||||
},
|
||||
getCacheGeneration: () => 0,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
service.start(0);
|
||||
await flushMicrotasks();
|
||||
service.stop();
|
||||
releaseFirst();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(cachedTexts, ['line-0']);
|
||||
});
|
||||
|
||||
test('prefetch service drops an in-flight result after cache invalidation', async () => {
|
||||
const cues = makeCues(5);
|
||||
const cachedTexts: string[] = [];
|
||||
let generation = 0;
|
||||
let releaseFirst!: () => void;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
|
||||
const service = createSubtitlePrefetchService({
|
||||
cues,
|
||||
tokenizeSubtitle: async (text) => {
|
||||
if (text === 'line-0') await firstGate;
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: (text) => {
|
||||
cachedTexts.push(text);
|
||||
},
|
||||
getCacheGeneration: () => generation,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
service.start(0);
|
||||
await flushMicrotasks();
|
||||
service.stop();
|
||||
generation += 1;
|
||||
releaseFirst();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(cachedTexts, []);
|
||||
});
|
||||
@@ -7,6 +7,12 @@ export interface SubtitlePrefetchServiceDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
hasCachedTokenization?: (text: string) => boolean;
|
||||
/**
|
||||
* Bumped whenever the tokenization cache is invalidated. A result is cached only if
|
||||
* the generation is unchanged since its tokenization began; without this dep every
|
||||
* result counts as fresh.
|
||||
*/
|
||||
getCacheGeneration?: () => number;
|
||||
priorityWindowSize?: number;
|
||||
}
|
||||
|
||||
@@ -88,9 +94,12 @@ export function createSubtitlePrefetchService(
|
||||
}
|
||||
warmedKeys.add(cacheKey);
|
||||
|
||||
// Freshness follows the cache generation, not the run: a seek or restart that
|
||||
// supersedes this run leaves the result valid, and the parser time is already spent.
|
||||
const generation = deps.getCacheGeneration?.();
|
||||
try {
|
||||
const result = await deps.tokenizeSubtitle(cue.text);
|
||||
if (result && !stopped && runId === currentRunId) {
|
||||
if (result && generation === deps.getCacheGeneration?.()) {
|
||||
deps.preCacheTokenization(cue.text, result);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface SubtitleProcessingController {
|
||||
*/
|
||||
notePlainSubtitleEmitted: (text: string) => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
/** Incremented by invalidateTokenizationCache; lets async writers detect stale results. */
|
||||
getCacheGeneration: () => number;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
hasCachedSubtitle: (text: string) => boolean;
|
||||
@@ -253,6 +255,7 @@ export function createSubtitleProcessingController(
|
||||
tokenizationCache.clear();
|
||||
cacheGeneration += 1;
|
||||
},
|
||||
getCacheGeneration: () => cacheGeneration,
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => {
|
||||
setCachedTokenization(text, data);
|
||||
},
|
||||
|
||||
@@ -1512,6 +1512,26 @@ test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds'
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 581);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle skips frequency requests for ranks supplied by the scanner', async () => {
|
||||
const deps = makeDepsFromYomitanTokens(
|
||||
[{ surface: '猫', reading: 'ねこ', headword: '猫', frequencyRank: 42 }],
|
||||
{ getFrequencyDictionaryEnabled: () => true },
|
||||
);
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
assert.ok(parserWindow);
|
||||
deps.getYomitanParserWindow = () => parserWindow;
|
||||
const scripts: string[] = [];
|
||||
const execute = parserWindow.webContents.executeJavaScript.bind(parserWindow.webContents);
|
||||
parserWindow.webContents.executeJavaScript = async (script) => {
|
||||
scripts.push(script);
|
||||
return execute(script);
|
||||
};
|
||||
const result = await tokenizeSubtitle('猫', deps);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 42);
|
||||
assert.ok(scripts.length > 0);
|
||||
assert.equal(scripts.filter((script) => script.includes('getTermFrequencies')).length, 0);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'第二走者',
|
||||
|
||||
@@ -494,6 +494,7 @@ function buildYomitanFrequencyTermReadingList(
|
||||
): Array<{ term: string; reading: string | null }> {
|
||||
const termReadingList: Array<{ term: string; reading: string | null }> = [];
|
||||
for (const token of tokens) {
|
||||
if (normalizePositiveFrequencyRank(token.frequencyRank) !== null) continue;
|
||||
const readingRaw =
|
||||
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
|
||||
for (const term of resolveYomitanFrequencyLookupTexts(token, matchMode)) {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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, failWrites = false;
|
||||
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 (failWrites) throw Error('offline');
|
||||
if (race) {
|
||||
race = false;
|
||||
options.anki.templates[0].tags = options.anki.tags = ['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, 'hachidori']],
|
||||
);
|
||||
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('leaves tags edited in Hachidori settings alone', async () => {
|
||||
const h = await harness({
|
||||
templates: [
|
||||
{ id: 'default', name: 'Default', deck: 'Mining', model: 'Japanese', tags: ['SubMiner'] },
|
||||
],
|
||||
});
|
||||
await h.sync();
|
||||
assert.deepEqual(await h.run('options.anki.tags'), ['SubMiner']);
|
||||
});
|
||||
|
||||
// SubMiner's new-card polling only watches ankiConnect.deck, so the first
|
||||
// template's deck follows it the way Yomitan's term card deck does.
|
||||
test('moves the first template to the SubMiner deck and preserves the rest of custom 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')) as Array<Record<string, unknown>>;
|
||||
await h.sync();
|
||||
assert.deepEqual(await h.run('options.anki.templates'), [
|
||||
{ ...before[0], deck: 'Mining' },
|
||||
...before.slice(1),
|
||||
]);
|
||||
assert.equal(await h.run('options.anki.deck'), 'Mining');
|
||||
});
|
||||
|
||||
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.deepEqual(await h.run('options.anki.tags'), ['User edit']);
|
||||
assert.equal(await h.run('options.anki.model'), 'Japanese');
|
||||
});
|
||||
|
||||
test('restores the previous proxy marker when every settings write fails', async () => {
|
||||
const h = await harness();
|
||||
await h.run(`proxy = 'http://127.0.0.1:9000'; failWrites = true`);
|
||||
await assert.rejects(h.sync(), /offline/);
|
||||
assert.equal(await h.run('proxy'), 'http://127.0.0.1:9000');
|
||||
});
|
||||
|
||||
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,126 @@
|
||||
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 } };
|
||||
// SubMiner's new-card polling only watches its configured deck, so the
|
||||
// first template follows it like Yomitan's term card deck.
|
||||
if (deck) template.deck = deck;
|
||||
// SubMiner's tags join Hachidori's own so cards show which backend mined
|
||||
// them. Only the untouched default is filled; edits in Hachidori settings win.
|
||||
if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) {
|
||||
template.tags = [...new Set([...hints.tags, 'hachidori'])];
|
||||
}
|
||||
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) {
|
||||
// The server was never written, so the marker must not claim it.
|
||||
await globalThis.__subminerSetAnkiProxyUrl(previousProxy);
|
||||
throw error;
|
||||
}
|
||||
// Re-read after a concurrent settings save before filling anything.
|
||||
}
|
||||
}
|
||||
};
|
||||
`;
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { importYomitanDictionaryFromZip } from './yomitan-parser-runtime';
|
||||
import { createDeps } from './yomitan-scan-test-harness';
|
||||
|
||||
// Hachidori deps whose sharing status reports `host`, recording every archive the
|
||||
// settings automation imports (locally or forwarded over the link).
|
||||
function hachidoriDeps(host: Record<string, unknown> | null) {
|
||||
const imported: string[] = [];
|
||||
const runScript = async (script: string): Promise<unknown> => {
|
||||
if (script.includes('hd_sharing_status')) {
|
||||
return {
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: host
|
||||
? { linked: true, connected: true, address: 'ws://desktop:8771/link', host }
|
||||
: { linked: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
const archiveUrl = /importDictionaryArchiveUrl\(\s*("[^"]+")/.exec(script)?.[1];
|
||||
if (archiveUrl) imported.push(await (await fetch(JSON.parse(archiveUrl))).text());
|
||||
return true;
|
||||
};
|
||||
const settingsWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => undefined,
|
||||
webContents: { executeJavaScript: runScript },
|
||||
};
|
||||
return {
|
||||
imported,
|
||||
deps: {
|
||||
...createDeps(runScript, { createYomitanExtensionWindow: async () => settingsWindow }),
|
||||
getYomitanExt: () => ({
|
||||
id: 'hachi',
|
||||
name: 'Hachidori',
|
||||
version: '1',
|
||||
path: '',
|
||||
url: '',
|
||||
manifest: {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeArchive(): Promise<string> {
|
||||
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
|
||||
await writeFile(zipPath, 'PK-test');
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
for (const [name, host] of [
|
||||
['an unlinked Hachidori imports the ZIP itself', null],
|
||||
[
|
||||
'a linked host that accepts imports gets the ZIP over the link',
|
||||
{ name: 'Helium', dictionaryCount: 8, capabilities: ['linked-import-v1'] },
|
||||
],
|
||||
] as const) {
|
||||
test(name, async () => {
|
||||
const { deps, imported } = hachidoriDeps(host);
|
||||
assert.equal(
|
||||
await importYomitanDictionaryFromZip(await writeArchive(), deps, { error: assert.fail }),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(imported, ['PK-test']);
|
||||
});
|
||||
}
|
||||
|
||||
test('a linked host without linked imports fails with the update or manual-import fix', async () => {
|
||||
const { deps, imported } = hachidoriDeps({ name: 'Chrome', dictionaryCount: 8 });
|
||||
const zipPath = await writeArchive();
|
||||
const errors: string[] = [];
|
||||
assert.equal(
|
||||
await importYomitanDictionaryFromZip(zipPath, deps, {
|
||||
error: (...parts: unknown[]) => errors.push(parts.join(' ')),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(imported, []);
|
||||
assert.match(
|
||||
errors[0] ?? '',
|
||||
/Update its Hachidori to 0\.2\.3 or later, or import .*merged\.zip/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
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: '' }] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type UploadReply = Record<string, unknown>;
|
||||
// The parts of vendor/hachidori/extension/linked-import.js the harness drives.
|
||||
type LinkedImportModule = {
|
||||
createUploadHost(options: {
|
||||
store: {
|
||||
append(token: string, data: string): void;
|
||||
discard(token: string): void;
|
||||
};
|
||||
importUpload(
|
||||
token: string,
|
||||
upload: { fileName: string; replace: boolean },
|
||||
): Promise<UploadReply>;
|
||||
setTimer(): number;
|
||||
clearTimer(): void;
|
||||
}): {
|
||||
begin(message: UploadReply, owner: string): UploadReply;
|
||||
chunk(message: UploadReply, owner: string): Promise<UploadReply>;
|
||||
commit(message: UploadReply, owner: string): Promise<UploadReply>;
|
||||
abort(message: UploadReply, owner: string): UploadReply;
|
||||
};
|
||||
};
|
||||
|
||||
async function createHarness(emptyLibrary = false) {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
let dictionaryRevision = 2;
|
||||
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', displayName: 'Main dictionary', enabled: true, revision: '1' },
|
||||
{ id: 'names', title: characterDictionary, enabled: true, revision: '1' },
|
||||
{
|
||||
id: 'frequency',
|
||||
title: 'Frequency',
|
||||
enabled: true,
|
||||
revision: '1',
|
||||
frequencyMode: 'rank-based',
|
||||
frequencyCount: 1,
|
||||
},
|
||||
];
|
||||
let duplicate = false;
|
||||
let proxyUrl: unknown;
|
||||
let loadingStatusReplies = 0;
|
||||
let busyEngineReplies = 0;
|
||||
const uploads: Array<{ fileName: string; replace: boolean; bytes: Buffer }> = [];
|
||||
const apiModule: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'api-host.js')).href
|
||||
);
|
||||
const linkedImportModule = (await import(
|
||||
pathToFileURL(path.join(extensionPath, 'linked-import.js')).href
|
||||
)) as LinkedImportModule;
|
||||
// Hachidori's own upload host, with the engine import replaced by a recorder.
|
||||
const uploadParts = new Map<string, Buffer[]>();
|
||||
const uploadHost = linkedImportModule.createUploadHost({
|
||||
store: {
|
||||
append: (token: string, data: string) => {
|
||||
uploadParts.set(token, [...(uploadParts.get(token) ?? []), Buffer.from(data, 'base64')]);
|
||||
},
|
||||
discard: (token: string) => {
|
||||
uploadParts.delete(token);
|
||||
},
|
||||
},
|
||||
importUpload: async (
|
||||
token: string,
|
||||
{ fileName, replace }: { fileName: string; replace: boolean },
|
||||
) => {
|
||||
uploads.push({ fileName, replace, bytes: Buffer.concat(uploadParts.get(token) ?? []) });
|
||||
return { ok: true, report: { success: true } };
|
||||
},
|
||||
setTimer: () => 0,
|
||||
clearTimer: () => {},
|
||||
});
|
||||
const context = vm.createContext({
|
||||
__testApiModule: apiModule,
|
||||
__testLinkedImportModule: linkedImportModule,
|
||||
setTimeout,
|
||||
crypto,
|
||||
URL,
|
||||
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.target === 'hachidori-linked-import') {
|
||||
// Mirrors background.js answerUploadRequest: commit answers with the import reply.
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'hd_import_begin':
|
||||
return { ok: true, ...uploadHost.begin(message, 'local') };
|
||||
case 'hd_import_chunk':
|
||||
return { ok: true, ...(await uploadHost.chunk(message, 'local')) };
|
||||
case 'hd_import_abort':
|
||||
return { ok: true, ...uploadHost.abort(message, 'local') };
|
||||
default:
|
||||
return await uploadHost.commit(message, 'local');
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
if (message.type === 'hd_status') {
|
||||
const loading = loadingStatusReplies > 0;
|
||||
if (loading) loadingStatusReplies -= 1;
|
||||
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_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_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('./linked-import.js')", '__testLinkedImportModule')
|
||||
.replace("await import('./reader-options.js')", 'Promise.resolve()');
|
||||
await vm.runInContext(script, context);
|
||||
const run = async (code: string): Promise<unknown> =>
|
||||
structuredClone(await vm.runInContext(code, context));
|
||||
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;
|
||||
},
|
||||
uploads: () => uploads,
|
||||
};
|
||||
}
|
||||
|
||||
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 frequency lookups match API headwords, readings and requested dictionaries', async () => {
|
||||
const harness = await createHarness();
|
||||
const query = (term: string, reading: string | null, dictionaries = ['Frequency']) =>
|
||||
harness.invoke('getTermFrequencies', { termReadingList: [{ term, reading }], dictionaries });
|
||||
assert.deepEqual(await query('食べる', 'たべる'), [
|
||||
{
|
||||
term: '食べる',
|
||||
reading: 'たべる',
|
||||
hasReading: false,
|
||||
dictionary: 'Frequency',
|
||||
frequency: 42,
|
||||
displayValue: null,
|
||||
displayValueParsed: false,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(await query('食べる', 'べつのよみ'), []);
|
||||
assert.deepEqual(await query('食べる', null, ['Other frequency']), []);
|
||||
assert.deepEqual(await query('食べるだけ', null), []);
|
||||
assert.deepEqual(await query('頻度だけ', null), []);
|
||||
assert.deepEqual(await query('食べる', null), await query('食べる', 'たべる'));
|
||||
});
|
||||
|
||||
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 mining requests render native dictionary aliases and frequency markers', async () => {
|
||||
const harness = await createHarness();
|
||||
await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail });
|
||||
const request = harness.messages.find((message) => message.type === 'hd_anki_preflight')?.request;
|
||||
assert.ok(request && typeof request === 'object');
|
||||
assert.ok('subminerEnrich' in request && request.subminerEnrich === false);
|
||||
const native: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'anki-values.js')).href
|
||||
);
|
||||
assert.ok(native && typeof native === 'object' && 'buildAnkiFields' in native);
|
||||
assert.equal(typeof native.buildAnkiFields, 'function');
|
||||
if (typeof native.buildAnkiFields !== 'function') assert.fail('Native renderer is unavailable');
|
||||
const fields: unknown = await native.buildAnkiFields(
|
||||
request,
|
||||
{
|
||||
Dictionary: { value: '{dictionary-alias}' },
|
||||
Frequency: { value: '{single-frequency-frequency}' },
|
||||
Rank: { value: '{frequency-harmonic-rank}' },
|
||||
},
|
||||
{},
|
||||
);
|
||||
assert.deepEqual(fields, {
|
||||
Dictionary: 'Main dictionary',
|
||||
Frequency: '<ul style="text-align: left;"><li>Frequency: </li></ul>',
|
||||
Rank: '42',
|
||||
});
|
||||
assert.ok('dictionaryIds' in request);
|
||||
assert.deepEqual(request.dictionaryIds, {
|
||||
JMdict: 'terms',
|
||||
[characterDictionary]: 'names',
|
||||
Frequency: 'frequency',
|
||||
});
|
||||
assert.deepEqual(
|
||||
harness.messages.find((message) => message.type === 'hd_anki_submit')?.request,
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori settings automation uploads ZIP bytes as a replacement and removes the matching dictionary ID', async () => {
|
||||
const harness = await createHarness();
|
||||
await harness.run(
|
||||
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEstdGVzdA==', 'characters.zip')",
|
||||
);
|
||||
assert.deepEqual(harness.uploads(), [
|
||||
{ fileName: 'characters.zip', replace: true, bytes: Buffer.from('PK-test') },
|
||||
]);
|
||||
await harness.run(
|
||||
`__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`,
|
||||
);
|
||||
const 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,275 @@
|
||||
// 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');
|
||||
const { uploadDictionary } = await import('./linked-import.js');
|
||||
await import('./reader-options.js');
|
||||
const send = async (type, fields = {}, target = 'hoshidicts-offscreen') => {
|
||||
const reply = await chrome.runtime.sendMessage({
|
||||
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 }) {
|
||||
const terms = [...new Set(termReadingList.map(pair => pair.term))];
|
||||
const { results } = await api({ type: 'hd_api_term_entries', terms });
|
||||
const frequencies = [];
|
||||
for (const result of results) {
|
||||
const term = terms[result.index];
|
||||
const pairs = termReadingList.filter(pair => pair.term === term);
|
||||
for (const entry of result.dictionaryEntries) {
|
||||
for (const value of entry.frequencies) {
|
||||
const headword = entry.headwords[value.headwordIndex];
|
||||
if (!headword || headword.term !== term || !dictionaries.includes(value.dictionary)) continue;
|
||||
if (!pairs.some(pair => pair.reading === null || pair.reading === headword.reading)) continue;
|
||||
// Upstream does not expose the frequency entry's original reading.
|
||||
// Keep its API flag and associate the value with the matched headword.
|
||||
frequencies.push({ term, reading: headword.reading || null,
|
||||
hasReading: value.hasReading, dictionary: value.dictionary,
|
||||
frequency: value.frequency, displayValue: value.displayValue,
|
||||
displayValueParsed: value.displayValueParsed });
|
||||
}
|
||||
}
|
||||
}
|
||||
return frequencies;
|
||||
}
|
||||
// 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 } }));
|
||||
};
|
||||
// Archives go through Hachidori's upload requests: an unlinked install imports
|
||||
// the ZIP itself and a linked one forwards it to its host. The engine queues
|
||||
// an import behind any dictionary change already running.
|
||||
const sendUpload = (type, fields) => chrome.runtime.sendMessage({
|
||||
target: 'hachidori-linked-import', type, requestId: crypto.randomUUID(), ...fields,
|
||||
});
|
||||
async function importArchive(blob, fileName) {
|
||||
await waitForEngineReady();
|
||||
const reply = await uploadDictionary({ blob, fileName, replace: true, send: sendUpload });
|
||||
if (reply?.report?.success !== true) {
|
||||
throw new Error(reply?.report?.error || reply?.error || 'Hachidori dictionary import failed');
|
||||
}
|
||||
}
|
||||
globalThis.__subminerYomitanSettingsAutomation = {
|
||||
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: [] };
|
||||
// Match the dictionary context supplied by Hachidori's popup to its
|
||||
// native glossary, alias, and frequency template renderers.
|
||||
const { dictionaries } = await readState();
|
||||
const frequencyModes = new Map(dictionaries.map(entry => [entry.title, entry.frequencyMode]));
|
||||
const term = { ...result.term, frequencies: result.term.frequencies.map(group =>
|
||||
({ ...group, frequencyMode: frequencyModes.get(group.dictionary) })) };
|
||||
const status = await send('hd_anki_status', {}, 'hachidori-anki');
|
||||
const request = {
|
||||
...result, term, generation: lookup.generation, sentence: word, searchQuery: word,
|
||||
matchOffset: 0, documentTitle: 'SubMiner', popupSelectionText: '',
|
||||
configKey: status.configKey, subminerEnrich: false,
|
||||
dictionaryAliases: Object.fromEntries(dictionaries.filter(entry => entry.displayName)
|
||||
.map(entry => [entry.title, entry.displayName])),
|
||||
dictionaryIds: Object.fromEntries(dictionaries.map(entry => [entry.title, entry.id])),
|
||||
frequencyDictionaries: dictionaries.filter(entry => entry.enabled !== false && entry.frequencyCount > 0)
|
||||
.map(entry => entry.title),
|
||||
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');
|
||||
};
|
||||
})();
|
||||
`;
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
import * as vm from 'node:vm';
|
||||
import {
|
||||
countTermsFindLookups,
|
||||
createBackendDeps,
|
||||
@@ -29,6 +30,102 @@ const TARGET_ANKI_SERVER = 'http://127.0.0.1:8766';
|
||||
const CUSTOM_ANKI_SERVER = 'http://192.168.1.5:8765';
|
||||
const CHARACTER_DICTIONARY = 'SubMiner Character Dictionary (AniList 130298)';
|
||||
|
||||
test('Yomitan restores direct Anki after disabling its managed proxy without a page helper', async () => {
|
||||
const options = { profiles: [{ options: { anki: { server: 'http://127.0.0.1:8765' } } }] };
|
||||
let managedUrl: string | null = null;
|
||||
const context = vm.createContext({
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({ subminerAnkiProxyUrl: managedUrl }),
|
||||
set: async (value: { subminerAnkiProxyUrl: string | null }) => {
|
||||
managedUrl = value.subminerAnkiProxyUrl;
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: (
|
||||
message: { action: string },
|
||||
callback: (response: { result: unknown }) => void,
|
||||
) => callback({ result: message.action === 'optionsGetFull' ? options : null }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const deps = createDeps(async (script) =>
|
||||
structuredClone(await vm.runInContext(script, context)),
|
||||
);
|
||||
const logger = { error: assert.fail };
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, logger, {
|
||||
forceOverride: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(managedUrl, 'http://127.0.0.1:8766');
|
||||
assert.equal(await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger), true);
|
||||
assert.equal(options.profiles[0]?.options.anki.server, 'http://127.0.0.1:8765');
|
||||
assert.equal(managedUrl, null);
|
||||
});
|
||||
|
||||
for (const failure of ['optionsGetFull', 'setAllSettings', 'storageSet']) {
|
||||
test(`Yomitan retries disabling its managed proxy after ${failure} fails`, async () => {
|
||||
const proxyUrl = 'http://127.0.0.1:8766';
|
||||
const directUrl = 'http://127.0.0.1:8765';
|
||||
let options = { profiles: [{ options: { anki: { server: proxyUrl } } }] };
|
||||
let managedUrl: string | null = proxyUrl;
|
||||
let shouldFail = true;
|
||||
const context = vm.createContext({
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({ subminerAnkiProxyUrl: managedUrl }),
|
||||
set: async (value: { subminerAnkiProxyUrl: string | null }) => {
|
||||
if (shouldFail && failure === 'storageSet') throw new Error('Storage unavailable');
|
||||
managedUrl = value.subminerAnkiProxyUrl;
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: (
|
||||
message: { action: string; params?: { value: typeof options } },
|
||||
callback: (response: { result?: unknown; error?: { message: string } }) => void,
|
||||
) => {
|
||||
if (shouldFail && message.action === failure) {
|
||||
callback({ error: { message: 'Settings unavailable' } });
|
||||
} else if (message.action === 'optionsGetFull') {
|
||||
callback({ result: structuredClone(options) });
|
||||
} else if (message.action === 'setAllSettings' && message.params) {
|
||||
options = structuredClone(message.params.value);
|
||||
callback({ result: null });
|
||||
} else {
|
||||
assert.fail(`Unexpected action: ${message.action}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const deps = createDeps(async (script) =>
|
||||
structuredClone(await vm.runInContext(script, context)),
|
||||
);
|
||||
const errors: string[] = [];
|
||||
const logger = { error: (message: string) => errors.push(message) };
|
||||
|
||||
assert.equal(await syncYomitanDefaultAnkiServer(directUrl, deps, logger), false);
|
||||
assert.equal(managedUrl, proxyUrl);
|
||||
assert.equal(
|
||||
options.profiles[0]?.options.anki.server,
|
||||
failure === 'storageSet' ? directUrl : proxyUrl,
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
|
||||
shouldFail = false;
|
||||
assert.equal(await syncYomitanDefaultAnkiServer(directUrl, deps, logger), true);
|
||||
assert.equal(options.profiles[0]?.options.anki.server, directUrl);
|
||||
assert.equal(managedUrl, null);
|
||||
assert.equal(errors.length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
interface AnkiSyncProfile {
|
||||
profileCurrent: number;
|
||||
profiles: Array<{ options: { anki: Record<string, unknown> } }>;
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { AnkiConnectConfig } from '../../../types';
|
||||
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
|
||||
import {
|
||||
buildHachidoriSharingScript,
|
||||
HACHIDORI_LINKED_IMPORT_CAPABILITY,
|
||||
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 +489,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 +608,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 +630,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 +674,27 @@ 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);
|
||||
} else {
|
||||
// did-finish-load precedes the search page's asynchronous backend initialization.
|
||||
await parserWindow.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (typeof window.__subminerAddNote !== 'function') {
|
||||
if (Date.now() >= deadline) throw new Error('Yomitan search page initialization timed out');
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
})()`,
|
||||
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 +737,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 +781,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 () => {
|
||||
@@ -827,6 +872,9 @@ async function serveDictionaryZipOnce<T>(
|
||||
}
|
||||
return await callback(`http://127.0.0.1:${address.port}${requestPath}`);
|
||||
} finally {
|
||||
// The extension's fetch keeps its socket open; close() alone would wait out
|
||||
// Node's keep-alive timeout before the import reports completion.
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
@@ -892,7 +940,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 +970,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 +1127,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 +1170,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 +1254,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 +1398,7 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
options?: {
|
||||
forceOverride?: boolean;
|
||||
deck?: string;
|
||||
ankiConfig?: AnkiConnectConfig;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const normalizedTargetServer = serverUrl.trim();
|
||||
@@ -1359,7 +1418,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 +1438,13 @@ 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,
|
||||
});
|
||||
}
|
||||
const { subminerAnkiProxyUrl: previousManagedProxy } = await chrome.storage.local.get('subminerAnkiProxyUrl');
|
||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
if (profiles.length === 0) {
|
||||
@@ -1405,8 +1471,11 @@ 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) {
|
||||
// A custom endpoint needs no settings change, but the proxy is no longer managed.
|
||||
await chrome.storage.local.set({ subminerAnkiProxyUrl: null });
|
||||
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
|
||||
}
|
||||
|
||||
@@ -1444,17 +1513,28 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
||||
}
|
||||
// Preserve the previous managed endpoint until settings are saved so failed switches can retry.
|
||||
await chrome.storage.local.set({ subminerAnkiProxyUrl: forceOverride ? targetServer : null });
|
||||
|
||||
if (!changed) {
|
||||
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer, targetDeck };
|
||||
}
|
||||
|
||||
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
||||
return { updated: true, matched: true, currentServer, targetServer, targetDeck };
|
||||
})();
|
||||
`;
|
||||
|
||||
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 +1644,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 +1666,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,
|
||||
@@ -1711,6 +1810,31 @@ export async function importYomitanDictionaryFromZip(
|
||||
return false;
|
||||
}
|
||||
|
||||
const extension = deps.getYomitanExt();
|
||||
if (extension && isHachidoriExtension(extension)) {
|
||||
try {
|
||||
const host = await requestHachidoriSharing({ type: 'hd_sharing_status' }, deps, logger);
|
||||
if (host.kind === 'disconnected' || host.kind === 'unavailable')
|
||||
throw new Error(host.message);
|
||||
// A linked host takes the ZIP over the link like a local import, below,
|
||||
// but only once it advertises linked imports (Hachidori 0.2.3+).
|
||||
if (
|
||||
host.kind === 'connected' &&
|
||||
!host.capabilities.includes(HACHIDORI_LINKED_IMPORT_CAPABILITY)
|
||||
) {
|
||||
throw new Error(
|
||||
`The linked ${host.name} cannot receive dictionary uploads. Update its Hachidori to 0.2.3 or later, or import ${normalizedZipPath} from its Hachidori settings.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Hachidori character dictionary import failed:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const supportsUrlImport = await invokeYomitanSettingsAutomation<boolean>(
|
||||
`
|
||||
(() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,8 +30,17 @@ export function createDeps(
|
||||
}
|
||||
|
||||
function createYomitanScriptSandbox(handler: (action: string, params: unknown) => unknown) {
|
||||
const storage: Record<string, unknown> = {};
|
||||
return {
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({ ...storage }),
|
||||
set: async (value: Record<string, unknown>) => {
|
||||
Object.assign(storage, value);
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
lastError: null,
|
||||
sendMessage: (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -188,6 +188,11 @@ export async function loadYomitanExtension(
|
||||
deps.setYomitanSession(targetSession);
|
||||
|
||||
try {
|
||||
if (!externalProfilePath) {
|
||||
// Electron may retain old extension scripts after an update, across app restarts.
|
||||
// Keep dictionaries/settings while ensuring the bundled worker loads current code.
|
||||
await targetSession.clearStorageData({ storages: ['serviceworkers'] });
|
||||
}
|
||||
const extensions = targetSession.extensions;
|
||||
const extension = await withSuppressedYomitanExtensionWarnings(() =>
|
||||
extensions
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+226
-25
@@ -33,6 +33,15 @@ import {
|
||||
screen,
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import {
|
||||
createHachidoriExtensionRuntime,
|
||||
getHachidoriSession,
|
||||
} from './core/services/hachidori-extension';
|
||||
import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime';
|
||||
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';
|
||||
@@ -387,6 +396,7 @@ import {
|
||||
installLauncher as installCommandLineLauncher,
|
||||
refreshManagedCommandLineLauncher,
|
||||
} from './main/runtime/command-line-launcher';
|
||||
import { applyLoginShellPath } from './main/runtime/login-shell-path';
|
||||
import {
|
||||
createWindowsMpvLaunchDeps,
|
||||
getConfiguredWindowsMpvPathStatus,
|
||||
@@ -902,14 +912,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,
|
||||
@@ -1007,6 +1037,8 @@ const {
|
||||
} = statsServerRuntime;
|
||||
|
||||
function requestAppQuit(): void {
|
||||
destroyYomitanSettingsWindow(hachidoriSettingsWindow);
|
||||
destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow);
|
||||
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
||||
appState.yomitanSettingsWindow = null;
|
||||
destroyStatsWindow();
|
||||
@@ -1463,21 +1495,45 @@ const resolveWindowsMpvShortcutRuntimePaths = () =>
|
||||
appDataDir: app.getPath('appData'),
|
||||
desktopDir: app.getPath('desktop'),
|
||||
});
|
||||
const createCommandLineLauncherRuntimeOptions = () => ({
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
homeDir: os.homedir(),
|
||||
localAppData: process.env.LOCALAPPDATA,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
cwd: process.cwd(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
appExePath: process.execPath,
|
||||
appVersion: app.getVersion(),
|
||||
bundledBunPath: app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bun', process.platform === 'win32' ? 'bun.exe' : 'bun')
|
||||
: undefined,
|
||||
});
|
||||
// Finder/Dock launches inherit launchd's minimal PATH; pick up the user's shell PATH so
|
||||
// launcher/Bun detection and spawned tools match what their terminal sees.
|
||||
const loginShellPathReady: Promise<void> =
|
||||
process.platform === 'darwin'
|
||||
? applyLoginShellPath({ env: process.env })
|
||||
.then((applied) => {
|
||||
if (!applied) logger.warn('Login shell PATH was empty; using inherited PATH');
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to read login shell PATH; using inherited PATH', error);
|
||||
})
|
||||
: Promise.resolve();
|
||||
const createCommandLineLauncherRuntimeOptions = async () => {
|
||||
await loginShellPathReady;
|
||||
return {
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
homeDir: os.homedir(),
|
||||
localAppData: process.env.LOCALAPPDATA,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
cwd: process.cwd(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
appExePath: process.execPath,
|
||||
appVersion: app.getVersion(),
|
||||
bundledBunPath: app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bun', process.platform === 'win32' ? 'bun.exe' : 'bun')
|
||||
: 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 () => {
|
||||
@@ -1550,10 +1606,10 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
shell.writeShortcutLink(shortcutPath, operation, details),
|
||||
});
|
||||
},
|
||||
detectCommandLineLauncher: () =>
|
||||
detectCommandLineLauncher(createCommandLineLauncherRuntimeOptions()),
|
||||
detectCommandLineLauncher: async () =>
|
||||
detectCommandLineLauncher(await createCommandLineLauncherRuntimeOptions()),
|
||||
installBun: async () => {
|
||||
const snapshot = await installCommandLineBun(createCommandLineLauncherRuntimeOptions());
|
||||
const snapshot = await installCommandLineBun(await createCommandLineLauncherRuntimeOptions());
|
||||
return {
|
||||
ok: snapshot.status === 'ready',
|
||||
message:
|
||||
@@ -1564,7 +1620,9 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
};
|
||||
},
|
||||
installCommandLineLauncher: async () => {
|
||||
const snapshot = await installCommandLineLauncher(createCommandLineLauncherRuntimeOptions());
|
||||
const snapshot = await installCommandLineLauncher(
|
||||
await createCommandLineLauncherRuntimeOptions(),
|
||||
);
|
||||
const ok = snapshot.status === 'ready' || snapshot.status === 'not_on_path';
|
||||
return {
|
||||
ok,
|
||||
@@ -2080,7 +2138,24 @@ function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolea
|
||||
return command[0] === 'seek' || command[0] === 'sub-seek';
|
||||
}
|
||||
|
||||
function getMpvPauseCommandValue(command: readonly (string | number)[]): 'yes' | 'no' | null {
|
||||
if ((command[0] !== 'set_property' && command[0] !== 'set') || command[1] !== 'pause') {
|
||||
return null;
|
||||
}
|
||||
return command[2] === 'yes' || command[2] === 'no' ? command[2] : null;
|
||||
}
|
||||
|
||||
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
|
||||
// Overlay auto-pause releases (popup closed, hover left) must not resume playback
|
||||
// behind an open timing review; the review applies them when it closes. A pause
|
||||
// request during the review keeps playback paused after it closes.
|
||||
const pauseValue = getMpvPauseCommandValue(rawCommand);
|
||||
if (pauseValue === 'no' && mediaTimingReviewRuntime.deferPlaybackResume()) {
|
||||
return;
|
||||
}
|
||||
if (pauseValue === 'yes') {
|
||||
mediaTimingReviewRuntime.cancelPlaybackResume();
|
||||
}
|
||||
const command =
|
||||
resolveSanitizedSubtitleSeekCommand(
|
||||
rawCommand,
|
||||
@@ -2146,6 +2221,7 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
subtitleProcessingController.preCacheTokenization(text, data);
|
||||
},
|
||||
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
|
||||
getCacheGeneration: () => subtitleProcessingController.getCacheGeneration(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||
@@ -2331,6 +2407,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();
|
||||
@@ -2682,8 +2763,11 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
warn: (message, ...args) => logger.warn(message, ...args),
|
||||
});
|
||||
},
|
||||
dictionaryImportReplacesExisting: () =>
|
||||
getYomitanParserRuntimeDeps().getYomitanExt()?.name === 'Hachidori',
|
||||
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
yomitanProfilePolicy.logSkippedWrite(
|
||||
@@ -3570,6 +3654,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,
|
||||
@@ -3585,6 +3671,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;
|
||||
@@ -3625,8 +3738,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;
|
||||
}
|
||||
@@ -5300,6 +5413,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(
|
||||
@@ -5312,6 +5460,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),
|
||||
@@ -5339,7 +5520,7 @@ const {
|
||||
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
|
||||
registerGlobalShortcutsCore,
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openYomitanSettings: () => openDictionarySettings(),
|
||||
isDev,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
}),
|
||||
@@ -6181,6 +6362,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
||||
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||
openHachidoriSettings: () => openHachidoriSettings(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||
@@ -6317,7 +6499,7 @@ runAndApplyStartupState();
|
||||
void app.whenReady().then(() => {
|
||||
void takePendingLauncherMigrationPath(async (pendingLauncherPath) => {
|
||||
const acknowledgedPaths = await refreshManagedCommandLineLauncher({
|
||||
...createCommandLineLauncherRuntimeOptions(),
|
||||
...(await createCommandLineLauncherRuntimeOptions()),
|
||||
additionalLauncherPaths: pendingLauncherPath ? [pendingLauncherPath] : [],
|
||||
});
|
||||
return pendingLauncherPath !== undefined && acknowledgedPaths.includes(pendingLauncherPath);
|
||||
@@ -6357,7 +6539,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']),
|
||||
@@ -6445,6 +6628,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(),
|
||||
@@ -6490,12 +6675,28 @@ 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);
|
||||
try {
|
||||
const extension = await hachidoriExtensionRuntime.ensureLoaded();
|
||||
deps.setYomitanExtension(extension);
|
||||
deps.setYomitanSession(getHachidoriSession());
|
||||
return extension;
|
||||
} catch (error) {
|
||||
logger.error('Failed to load Hachidori extension:', error);
|
||||
deps.setYomitanExtension(null);
|
||||
deps.setYomitanSession(null);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
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;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -136,6 +137,7 @@ function createCliCommandDepsFromContext(
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
openHachidoriSettings: context.openHachidoriSettings,
|
||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||
openSyncUiWindow: context.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: context.openYoutubeBrowserWindow,
|
||||
|
||||
@@ -185,6 +185,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'];
|
||||
openYoutubeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openYoutubeBrowserWindow'];
|
||||
@@ -372,6 +373,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,
|
||||
openYoutubeBrowserWindow: params.ui.openYoutubeBrowserWindow,
|
||||
|
||||
@@ -56,6 +56,7 @@ export class FakeOverlayWindow {
|
||||
private readonly name: string;
|
||||
private readonly log: string[] | undefined;
|
||||
private loadListeners: Array<() => void> = [];
|
||||
private stopLoadingListeners: Array<() => void> = [];
|
||||
private readyToShowListeners: Array<() => void> = [];
|
||||
|
||||
readonly webContents = {
|
||||
@@ -68,8 +69,10 @@ export class FakeOverlayWindow {
|
||||
focus: (): void => {
|
||||
this.webContentsFocused = true;
|
||||
},
|
||||
once: (_event: 'did-finish-load', listener: () => void): void => {
|
||||
this.loadListeners.push(listener);
|
||||
once: (event: 'did-finish-load' | 'did-stop-loading', listener: () => void): void => {
|
||||
(event === 'did-stop-loading' ? this.stopLoadingListeners : this.loadListeners).push(
|
||||
listener,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -136,12 +139,14 @@ export class FakeOverlayWindow {
|
||||
this.readyToShowListeners.push(listener);
|
||||
}
|
||||
|
||||
// Commits the modal document and fires the queued did-finish-load listeners.
|
||||
// Commits the modal document like Electron does: isLoading() stays true inside
|
||||
// did-finish-load and only settles before did-stop-loading.
|
||||
finishLoad(): void {
|
||||
this.loading = false;
|
||||
this.url = MODAL_URL;
|
||||
this[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = true;
|
||||
for (const listener of this.loadListeners.splice(0)) listener();
|
||||
this.loading = false;
|
||||
for (const listener of this.stopLoadingListeners.splice(0)) listener();
|
||||
}
|
||||
|
||||
// Marks renderer content ready and fires the queued ready-to-show listeners.
|
||||
|
||||
@@ -131,6 +131,26 @@ for (const platform of ['darwin', 'win32'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
test('sendToActiveOverlayWindow delivers to a modal window created for the send', () => {
|
||||
// ready-to-show can fire before the document loads, and Electron still reports
|
||||
// isLoading() inside did-finish-load; only did-stop-loading sees the settled state.
|
||||
const modal = new FakeOverlayWindow({ ...LOADING_BLANK_WINDOW, documentLoaded: false });
|
||||
const h = createHarness({ createModal: () => modal });
|
||||
|
||||
assert.equal(
|
||||
h.runtime.sendToActiveOverlayWindow(
|
||||
'media-timing-review:open',
|
||||
{ reviewId: 'r1' },
|
||||
{ restoreOnModalClose: 'media-timing-review', preferModalWindow: true },
|
||||
),
|
||||
true,
|
||||
);
|
||||
modal.emitReadyToShow();
|
||||
assert.deepEqual(modal.sent, []);
|
||||
modal.finishLoad();
|
||||
assert.deepEqual(modal.sent, [['media-timing-review:open', { reviewId: 'r1' }]]);
|
||||
});
|
||||
|
||||
test('primeModalWindow leaves Linux modal creation lazy', () => {
|
||||
const h = createHarness({ createModal: () => new FakeOverlayWindow(), platform: 'linux' });
|
||||
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -179,6 +179,45 @@ const konosuba = { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba'
|
||||
const towerOfGod = { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God' };
|
||||
const eminence = { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence' };
|
||||
|
||||
test('replacement-capable imports retain the installed dictionary when an update fails', async () => {
|
||||
for (const succeeds of [true, false]) {
|
||||
let imported = false;
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath: makeTempDir(),
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 7,
|
||||
mediaTitle: 'Frieren',
|
||||
entryCount: 100,
|
||||
fromCache: true,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/replacement.zip',
|
||||
revision: 'new',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 100,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () => [
|
||||
{ title: 'SubMiner Character Dictionary', revision: 'old' },
|
||||
],
|
||||
dictionaryImportReplacesExisting: () => true,
|
||||
importYomitanDictionary: async () => {
|
||||
imported = true;
|
||||
return succeeds;
|
||||
},
|
||||
deleteYomitanDictionary: async () => {
|
||||
assert.fail('must keep the old dictionary until replacement succeeds');
|
||||
},
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
});
|
||||
if (succeeds) await runtime.runSyncNow();
|
||||
else await assert.rejects(runtime.runSyncNow(), /Failed to import dictionary ZIP/);
|
||||
assert.equal(imported, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('character dictionary manager snapshots, reorders, and removes MRU entries', () => {
|
||||
const userDataPath = makeTempDir();
|
||||
writeState(userDataPath, {
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||
waitForYomitanMutationReady?: () => Promise<void>;
|
||||
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
dictionaryImportReplacesExisting?: () => boolean;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
upsertYomitanDictionarySettings: (
|
||||
dictionaryTitle: string,
|
||||
@@ -669,7 +670,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
const importTimeoutMs = resolveImportTimeoutMs(
|
||||
merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'),
|
||||
);
|
||||
if (existing !== null) {
|
||||
if (existing !== null && deps.dictionaryImportReplacesExisting?.() !== true) {
|
||||
await withTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -108,6 +109,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||
|
||||
@@ -72,6 +72,7 @@ function buildContextDeps(overrides: Partial<MainDeps> = {}) {
|
||||
runUpdateCommand: noopAsync,
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: noopAsync,
|
||||
runYoutubePlaybackFlow: noopAsync,
|
||||
openHachidoriSettings: noop,
|
||||
openYomitanSettings: noop,
|
||||
openConfigSettingsWindow: noop,
|
||||
openSyncUiWindow: noop,
|
||||
|
||||
@@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -145,6 +146,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(),
|
||||
openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(),
|
||||
|
||||
@@ -52,6 +52,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;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -135,6 +136,7 @@ export function createCliCommandContext(
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow,
|
||||
|
||||
@@ -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,
|
||||
youtubeBrowser: false,
|
||||
@@ -763,3 +769,151 @@ 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('reopening setup for legacy plugin cleanup preserves both backend completions', async () => {
|
||||
await withTempDir(async (configDir) => {
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
const yomitan = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
detectLegacyMpvPluginCandidates: () => [
|
||||
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
|
||||
],
|
||||
});
|
||||
await yomitan.ensureSetupStateInitialized();
|
||||
const reopened = await yomitan.markSetupInProgress();
|
||||
assert.equal(reopened.state.status, 'in_progress');
|
||||
assert.deepEqual(reopened.state.completedDictionaryBackends, ['yomitan']);
|
||||
assert.equal(yomitan.isSetupCompleted(), false);
|
||||
|
||||
const hachidori = createFirstRunSetupService({
|
||||
configDir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const switched = await hachidori.ensureSetupStateInitialized();
|
||||
assert.deepEqual(switched.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
|
||||
const restored = await yomitan.getSetupStatus();
|
||||
assert.equal(restored.state.status, 'completed');
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
capabilities: [],
|
||||
};
|
||||
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');
|
||||
// The link is still connecting at startup; that must not undo a finished setup.
|
||||
host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Connecting' };
|
||||
snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.canFinish, false);
|
||||
assert.equal(snapshot.dictionaryCount, 0);
|
||||
assert.equal(snapshot.state.status, 'completed');
|
||||
assert.equal(service.isSetupCompleted(), true);
|
||||
host = {
|
||||
kind: 'connected',
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
name: 'Docker',
|
||||
dictionaryCount: 0,
|
||||
capabilities: [],
|
||||
};
|
||||
assert.equal((await service.getSetupStatus()).canFinish, false);
|
||||
// A reachable host with no dictionaries does reopen setup.
|
||||
assert.equal((await service.ensureSetupStateInitialized()).state.status, 'incomplete');
|
||||
assert.notEqual((await service.markSetupCompleted()).state.status, 'completed');
|
||||
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,47 @@ 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();
|
||||
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] : []),
|
||||
]),
|
||||
];
|
||||
if (storedBackend === backend) return { ...stored, completedDictionaryBackends };
|
||||
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]
|
||||
: (state.completedDictionaryBackends ?? []),
|
||||
};
|
||||
writeSetupState(setupStatePath, state);
|
||||
completed = state.status === 'completed';
|
||||
deps.onStateChanged?.(state);
|
||||
@@ -292,10 +376,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 +400,8 @@ export function createFirstRunSetupService(deps: {
|
||||
installedWindowsMpvShortcuts,
|
||||
);
|
||||
return {
|
||||
dictionaryBackend: getDictionaryBackend(),
|
||||
hachidoriHost,
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
canFinish: isYomitanSetupSatisfied({
|
||||
@@ -353,11 +441,19 @@ export function createFirstRunSetupService(deps: {
|
||||
|
||||
return {
|
||||
ensureSetupStateInitialized: async () => {
|
||||
const state = readState();
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured } =
|
||||
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, hachidoriHost } =
|
||||
await resolveYomitanSetupStatus({
|
||||
configFilePaths,
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
getDictionaryBackend,
|
||||
getHachidoriHostStatus: deps.getHachidoriHostStatus,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const canFinish = isYomitanSetupSatisfied({
|
||||
@@ -365,7 +461,11 @@ export function createFirstRunSetupService(deps: {
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
});
|
||||
if (isSetupCompleted(state) && canFinish) {
|
||||
// A linked host is usually still connecting at startup, so an unreachable host
|
||||
// says nothing about its dictionaries; only a reachable empty host reopens setup.
|
||||
const hostUnreachable =
|
||||
hachidoriHost?.kind === 'disconnected' || hachidoriHost?.kind === 'unavailable';
|
||||
if (isSetupCompleted(state) && (canFinish || (configReady && hostUnreachable))) {
|
||||
completed = true;
|
||||
return refreshWithState(state);
|
||||
}
|
||||
|
||||
@@ -727,3 +727,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;
|
||||
@@ -251,9 +259,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
|
||||
@@ -270,8 +316,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>
|
||||
@@ -340,6 +386,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;
|
||||
@@ -468,17 +529,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>
|
||||
@@ -497,6 +559,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' &&
|
||||
@@ -509,6 +573,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,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import { applyLoginShellPath, mergePathValues, readLoginShellPath } from './login-shell-path';
|
||||
|
||||
const wrap = (value: string) =>
|
||||
`motd noise\n__SUBMINER_LOGIN_PATH__${value}\n__SUBMINER_LOGIN_PATH__`;
|
||||
|
||||
test('readLoginShellPath extracts PATH between markers, ignoring rc-file output', async () => {
|
||||
let invoked: { shell: string; args: string[] } | null = null;
|
||||
const value = await readLoginShellPath({
|
||||
env: { SHELL: '/bin/zsh' },
|
||||
runShell: async (shell, args) => {
|
||||
invoked = { shell, args };
|
||||
return wrap('/Users/me/.local/bin:/usr/bin');
|
||||
},
|
||||
});
|
||||
assert.equal(value, '/Users/me/.local/bin:/usr/bin');
|
||||
assert.equal(invoked!.shell, '/bin/zsh');
|
||||
assert.equal(invoked!.args[0], '-ilc');
|
||||
});
|
||||
|
||||
test('readLoginShellPath returns null when markers are missing', async () => {
|
||||
const value = await readLoginShellPath({ env: {}, shell: '/bin/sh', runShell: async () => '' });
|
||||
assert.equal(value, null);
|
||||
});
|
||||
|
||||
test('readLoginShellPath rejects relative explicit shells before execution', async () => {
|
||||
for (const shell of ['zsh', './zsh', '']) {
|
||||
await assert.rejects(
|
||||
readLoginShellPath({
|
||||
env: { SHELL: '/bin/zsh' },
|
||||
shell,
|
||||
runShell: async () => assert.fail('Invalid shell must not execute'),
|
||||
}),
|
||||
/Login shell must be an absolute path/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('readLoginShellPath falls back through absolute default shells', async () => {
|
||||
const originalUserInfo = os.userInfo;
|
||||
try {
|
||||
for (const shell of ['/bin/bash', 'bash', '', null]) {
|
||||
Object.defineProperty(os, 'userInfo', {
|
||||
value: () => ({ shell, username: 'test', uid: 1000, gid: 1000, homedir: '/Users/test' }),
|
||||
});
|
||||
await readLoginShellPath({
|
||||
env: { SHELL: './zsh' },
|
||||
runShell: async (selectedShell) => {
|
||||
assert.equal(selectedShell, shell === '/bin/bash' ? '/bin/bash' : '/bin/zsh');
|
||||
return wrap('/usr/bin');
|
||||
},
|
||||
});
|
||||
}
|
||||
Object.defineProperty(os, 'userInfo', {
|
||||
value: () => {
|
||||
throw new Error('User lookup failed');
|
||||
},
|
||||
});
|
||||
await readLoginShellPath({
|
||||
env: { SHELL: 'zsh' },
|
||||
runShell: async (shell) => {
|
||||
assert.equal(shell, '/bin/zsh');
|
||||
return wrap('/usr/bin');
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
os.userInfo = originalUserInfo;
|
||||
}
|
||||
});
|
||||
|
||||
test('mergePathValues puts login entries first and keeps process-only entries', () => {
|
||||
assert.equal(
|
||||
mergePathValues('/Users/me/.local/bin:/usr/bin:/bin', '/usr/bin:/bin:/usr/sbin:/sbin'),
|
||||
'/Users/me/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyLoginShellPath updates env.PATH so launcher detection sees shell dirs', async () => {
|
||||
const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin' };
|
||||
const applied = await applyLoginShellPath({
|
||||
env,
|
||||
shell: '/bin/zsh',
|
||||
runShell: async () => wrap('/Users/me/.local/bin:/usr/bin'),
|
||||
});
|
||||
assert.equal(applied, true);
|
||||
assert.equal(env.PATH, '/Users/me/.local/bin:/usr/bin:/bin');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const MARKER = '__SUBMINER_LOGIN_PATH__';
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
|
||||
export type RunShell = (shell: string, args: string[], timeoutMs: number) => Promise<string>;
|
||||
|
||||
type LoginShellPathOptions = {
|
||||
env: NodeJS.ProcessEnv;
|
||||
shell?: string;
|
||||
runShell?: RunShell;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const runShellDefault: RunShell = (shell, args, timeoutMs) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
shell,
|
||||
args,
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DISABLE_AUTO_UPDATE: 'true' },
|
||||
},
|
||||
(error, stdout) => (error ? reject(error) : resolve(stdout)),
|
||||
);
|
||||
child.stdin?.end();
|
||||
});
|
||||
|
||||
function defaultShell(env: NodeJS.ProcessEnv): string {
|
||||
if (env.SHELL && isAbsolute(env.SHELL)) return env.SHELL;
|
||||
try {
|
||||
const shell = os.userInfo().shell;
|
||||
return shell && isAbsolute(shell) ? shell : '/bin/zsh';
|
||||
} catch {
|
||||
return '/bin/zsh';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads PATH as the user's interactive login shell sees it. macOS GUI apps inherit
|
||||
* launchd's minimal PATH, so dirs added in ~/.zshrc and friends are otherwise invisible.
|
||||
*/
|
||||
export async function readLoginShellPath(options: LoginShellPathOptions): Promise<string | null> {
|
||||
const shell = options.shell ?? defaultShell(options.env);
|
||||
if (!isAbsolute(shell)) throw new Error('Login shell must be an absolute path');
|
||||
const run = options.runShell ?? runShellDefault;
|
||||
// printenv keeps this shell-agnostic (fish exposes $PATH as a list).
|
||||
const command = `printf '%s' '${MARKER}'; /usr/bin/printenv PATH; printf '%s' '${MARKER}'`;
|
||||
const stdout = await run(shell, ['-ilc', command], options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
const value = stdout.split(MARKER)[1]?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/** Puts login-shell entries first (terminal precedence), keeping any process-only entries after. */
|
||||
export function mergePathValues(loginPath: string, currentPath: string | undefined): string {
|
||||
const merged: string[] = [];
|
||||
for (const entry of [...loginPath.split(':'), ...(currentPath ?? '').split(':')]) {
|
||||
if (entry && !merged.includes(entry)) merged.push(entry);
|
||||
}
|
||||
return merged.join(':');
|
||||
}
|
||||
|
||||
/** Merges the login-shell PATH into `options.env.PATH`. Resolves false when it couldn't be read. */
|
||||
export async function applyLoginShellPath(options: LoginShellPathOptions): Promise<boolean> {
|
||||
const loginPath = await readLoginShellPath(options);
|
||||
if (!loginPath) return false;
|
||||
options.env.PATH = mergePathValues(loginPath, options.env.PATH);
|
||||
return true;
|
||||
}
|
||||
@@ -372,12 +372,19 @@ test('Windows stages a new runtime version while the prior Bun executable is run
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
cleanupOldWindowsManagedRuntimes({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
assert.equal(fs.existsSync(path.dirname(first.bunPath)), false);
|
||||
// Windows can keep bun.exe locked briefly after the exit event; cleanup
|
||||
// skips locked runtimes, so retry the way the next launch would.
|
||||
const firstDirectory = path.dirname(first.bunPath);
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
cleanupOldWindowsManagedRuntimes({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
if (!fs.existsSync(firstDirectory)) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
assert.equal(fs.existsSync(firstDirectory), false);
|
||||
assert.ok(fs.existsSync(expectedSecond.bunPath));
|
||||
});
|
||||
|
||||
|
||||
@@ -95,9 +95,11 @@ async function startActiveMediaTimingReview(
|
||||
decisionTimeoutMs?: number;
|
||||
generateWaveform?: () => Promise<number[]>;
|
||||
play?: () => Promise<void>;
|
||||
paused?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const previewCalls: Array<[number, number]> = [];
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
|
||||
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
|
||||
publishPayload = resolve;
|
||||
@@ -106,8 +108,9 @@ async function startActiveMediaTimingReview(
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
|
||||
send: () => undefined,
|
||||
requestProperty: async (name) =>
|
||||
name === 'duration' ? 100 : name === 'pause' ? (options.paused ?? true) : null,
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
@@ -138,7 +141,7 @@ async function startActiveMediaTimingReview(
|
||||
maxMediaDuration: options.maxMediaDuration ?? 30,
|
||||
});
|
||||
|
||||
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
|
||||
return { runtime, payload: await openedPayload, pendingDecision, previewCalls, commands };
|
||||
}
|
||||
|
||||
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
|
||||
@@ -646,6 +649,105 @@ test('collectMediaTimingContextLines falls back to played history when no cues a
|
||||
assert.deepEqual(context.next, []);
|
||||
});
|
||||
|
||||
test('media timing review keeps an already-paused video paused when nothing asks to resume', async () => {
|
||||
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
|
||||
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
await pendingDecision;
|
||||
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
|
||||
test('media timing review holds overlay resume requests until the review closes', async () => {
|
||||
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
|
||||
|
||||
assert.equal(runtime.deferPlaybackResume(), true);
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
await pendingDecision;
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
assert.equal(runtime.deferPlaybackResume(), false);
|
||||
});
|
||||
|
||||
for (const paused of [true, false]) {
|
||||
test(`media timing review stays paused after an overlay pause request (paused before: ${paused})`, async () => {
|
||||
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview({
|
||||
paused,
|
||||
});
|
||||
|
||||
assert.equal(runtime.deferPlaybackResume(), true);
|
||||
runtime.cancelPlaybackResume();
|
||||
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
await pendingDecision;
|
||||
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
}
|
||||
|
||||
test('media timing review honors an overlay pause that arrives while setup reads the pause state', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const pauseReads: Array<(paused: boolean) => void> = [];
|
||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: (name) =>
|
||||
name === 'pause'
|
||||
? new Promise((resolve) => pauseReads.push(resolve))
|
||||
: Promise.resolve(name === 'duration' ? 100 : null),
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
});
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
const request = {
|
||||
kind: 'sentence' as const,
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
};
|
||||
|
||||
const first = runtime.requestReview(request);
|
||||
runtime.cancelPlaybackResume();
|
||||
// mpv answered the read before the overlay's pause reached it.
|
||||
pauseReads[0]!(false);
|
||||
await first;
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
|
||||
commands.length = 0;
|
||||
const second = runtime.requestReview(request);
|
||||
pauseReads[1]!(false);
|
||||
await second;
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('media timing review watchdog falls back when the renderer stops responding', async () => {
|
||||
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
|
||||
|
||||
|
||||
@@ -280,6 +280,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
let active: ActiveReview | null = null;
|
||||
let currentRequest: ReviewRequestLifecycle | null = null;
|
||||
let pendingPauseRestore: ReviewMpvClient | null = null;
|
||||
let resumeDeferred = false;
|
||||
/** Set when the overlay pauses during the current request, even before its setup reads the pause state. */
|
||||
let restoreCancelled = false;
|
||||
|
||||
function restorePendingPlayback(): void {
|
||||
const mpvClient = pendingPauseRestore;
|
||||
@@ -289,6 +292,37 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
}
|
||||
}
|
||||
|
||||
function resumeDeferredPlayback(): void {
|
||||
if (!resumeDeferred) return;
|
||||
resumeDeferred = false;
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (mpvClient?.connected) {
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds an overlay request to resume playback (e.g. an auto-pause released because the
|
||||
* dictionary popup closed) until the pending review ends, then applies it. Returns false
|
||||
* when no review holds playback, so the caller should resume right away.
|
||||
*/
|
||||
function deferPlaybackResume(): boolean {
|
||||
if (!active && !currentRequest) return false;
|
||||
resumeDeferred = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay asked to pause while a review holds playback, so closing the review must
|
||||
* not resume it: drops both a held overlay resume and the review's own restore.
|
||||
*/
|
||||
function cancelPlaybackResume(): void {
|
||||
if (!currentRequest) return;
|
||||
resumeDeferred = false;
|
||||
restoreCancelled = true;
|
||||
if (active) active.restorePlayback = false;
|
||||
}
|
||||
|
||||
function ensureWindow(
|
||||
review: ActiveReview,
|
||||
range: RemoteMediaWindowRange,
|
||||
@@ -413,7 +447,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
|
||||
setup.values;
|
||||
const pauseState = booleanProperty(pauseRaw);
|
||||
pendingPauseRestore = pauseState === false ? mpvClient : null;
|
||||
// The pause read can predate an overlay pause that arrived during setup.
|
||||
pendingPauseRestore = pauseState === false && !restoreCancelled ? mpvClient : null;
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
||||
if (lifecycle.isCancelled()) {
|
||||
restorePendingPlayback();
|
||||
@@ -531,6 +566,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
}
|
||||
const lifecycle = createReviewRequestLifecycle();
|
||||
currentRequest = lifecycle;
|
||||
restoreCancelled = false;
|
||||
try {
|
||||
return await runReview(request, lifecycle);
|
||||
} catch {
|
||||
@@ -541,6 +577,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
} finally {
|
||||
if (currentRequest === lifecycle) {
|
||||
currentRequest = null;
|
||||
resumeDeferredPlayback();
|
||||
}
|
||||
lifecycle.markSettled();
|
||||
}
|
||||
@@ -739,7 +776,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
if (!current) return;
|
||||
deps.clearFrameCache?.();
|
||||
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
|
||||
if (current.restorePlayback && current.mpvClient.connected) {
|
||||
if ((current.restorePlayback || resumeDeferred) && current.mpvClient.connected) {
|
||||
resumeDeferred = false;
|
||||
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||
}
|
||||
}
|
||||
@@ -760,6 +798,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
getFrame,
|
||||
stopPreview,
|
||||
resolveReview,
|
||||
deferPlaybackResume,
|
||||
cancelPlaybackResume,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import * as vm from 'node:vm';
|
||||
import { createDeps } from '../../core/services/tokenizer/yomitan-scan-test-harness';
|
||||
import { DEFAULT_CONFIG } from '../../config';
|
||||
import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service';
|
||||
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
|
||||
@@ -30,6 +32,7 @@ function createDeferred<T>() {
|
||||
function createRuntimeHarness(
|
||||
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
|
||||
backgroundState: BackgroundStatsServerState | null = null,
|
||||
overrides: Partial<StatsServerRuntimeDeps> = {},
|
||||
) {
|
||||
const appStateValues: Array<StatsServer | null> = [];
|
||||
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
|
||||
@@ -65,10 +68,54 @@ function createRuntimeHarness(
|
||||
removeBackgroundStatsServerState: () => {},
|
||||
isBackgroundStatsServerProcessAlive: () => false,
|
||||
startServer,
|
||||
...overrides,
|
||||
});
|
||||
return { runtime, appStateValues };
|
||||
}
|
||||
|
||||
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
|
||||
const settings: unknown[] = [];
|
||||
const context = vm.createContext({
|
||||
chrome: {},
|
||||
__subminerSyncAnkiSettings: async (value: unknown) => {
|
||||
settings.push(value);
|
||||
return { updated: true, matched: true };
|
||||
},
|
||||
__subminerAddNote: async () => ({ noteId: 123, duplicateNoteIds: [] }),
|
||||
});
|
||||
vm.runInContext('window = globalThis', context);
|
||||
const configs: Parameters<NonNullable<StatsServerRuntimeDeps['startServer']>>[0][] = [];
|
||||
const { runtime } = createRuntimeHarness(
|
||||
async (config) => {
|
||||
configs.push(config);
|
||||
return { close: async () => {} };
|
||||
},
|
||||
null,
|
||||
{
|
||||
...createDeps(async (script) => structuredClone(await vm.runInContext(script, context))),
|
||||
getResolvedConfig: () => ({
|
||||
...DEFAULT_CONFIG,
|
||||
ankiConnect: {
|
||||
...DEFAULT_CONFIG.ankiConnect,
|
||||
enabled: true,
|
||||
url: 'http://127.0.0.1:8765',
|
||||
proxy: {
|
||||
...DEFAULT_CONFIG.ankiConnect.proxy,
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await runtime.ensureStatsServerStarted();
|
||||
assert.equal(await configs[0]?.addYomitanNote?.('入れる'), 123);
|
||||
assert.ok(settings[0] && typeof settings[0] === 'object' && 'server' in settings[0]);
|
||||
assert.equal(settings[0].server, 'http://127.0.0.1:8766');
|
||||
await runtime.stopStatsServer();
|
||||
});
|
||||
|
||||
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = createStatsServerRuntime({
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
writeBackgroundStatsServerState,
|
||||
} from './stats-daemon';
|
||||
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
|
||||
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
} from './yomitan-anki-server';
|
||||
|
||||
export function isSelfOwnedBackgroundStatsDaemonState(state: {
|
||||
pid: number;
|
||||
@@ -171,10 +174,11 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
generateSentenceFurigana(text, highlightedText, yomitanDeps, yomitanLogger),
|
||||
addYomitanNote: async (word: string) => {
|
||||
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||
const ankiUrl = getPreferredYomitanAnkiServerUrl(ankiConnectConfig);
|
||||
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) {
|
||||
|
||||
@@ -251,3 +251,99 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues'
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
|
||||
});
|
||||
|
||||
function createTrackingController(loadContent: () => string) {
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
const events: string[] = [];
|
||||
const cueUpdates: Array<string | null> = [];
|
||||
let serviceCount = 0;
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async () => loadContent(),
|
||||
parseSubtitleCues: (content): SubtitleCue[] => [{ startTime: 0, endTime: 1, text: content }],
|
||||
createSubtitlePrefetchService: () => {
|
||||
const id = ++serviceCount;
|
||||
return {
|
||||
start: () => events.push(`start:${id}`),
|
||||
stop: () => events.push(`stop:${id}`),
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
};
|
||||
},
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues, source) => {
|
||||
cueUpdates.push(cues ? `${source}:${cues[0]!.text}` : null);
|
||||
},
|
||||
});
|
||||
|
||||
return { controller, events, cueUpdates };
|
||||
}
|
||||
|
||||
test('re-initializing an unchanged source keeps the running service and republishes cues', async () => {
|
||||
const { controller, events, cueUpdates } = createTrackingController(() => 'content');
|
||||
|
||||
await controller.initSubtitlePrefetch('track-1.srt', 0);
|
||||
await controller.initSubtitlePrefetch('track-1.srt', 3);
|
||||
|
||||
assert.deepEqual(events, ['start:1']);
|
||||
assert.deepEqual(cueUpdates, ['track-1.srt:content', 'track-1.srt:content']);
|
||||
});
|
||||
|
||||
test('re-initializing a source whose content changed restarts the service', async () => {
|
||||
let content = 'first';
|
||||
const { controller, events } = createTrackingController(() => content);
|
||||
|
||||
await controller.initSubtitlePrefetch('track-1.srt', 0);
|
||||
content = 'second';
|
||||
await controller.initSubtitlePrefetch('track-1.srt', 0);
|
||||
|
||||
assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']);
|
||||
});
|
||||
|
||||
test('subtitle prefetch init warms overlap lines without publishing them as cues', async () => {
|
||||
let prefetchedTexts: string[] = [];
|
||||
let publishedTexts: string[] = [];
|
||||
let currentService: SubtitlePrefetchService | null = null;
|
||||
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => currentService,
|
||||
setCurrentService: (service) => {
|
||||
currentService = service;
|
||||
},
|
||||
loadSubtitleSourceText: async () => 'content',
|
||||
parseSubtitleCues: (): SubtitleCue[] => [
|
||||
{ startTime: 0, endTime: 4, text: 'first' },
|
||||
{ startTime: 2, endTime: 6, text: 'second' },
|
||||
],
|
||||
createSubtitlePrefetchService: ({ cues }) => {
|
||||
prefetchedTexts = cues.map((cue) => cue.text);
|
||||
return {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
onSeek: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
};
|
||||
},
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues) => {
|
||||
publishedTexts = cues?.map((cue) => cue.text) ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
await controller.initSubtitlePrefetch('track.srt', 0);
|
||||
|
||||
assert.deepEqual(prefetchedTexts, ['first', 'second', 'first\n\nsecond']);
|
||||
assert.deepEqual(publishedTexts, ['first', 'second']);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '../../core/services/subtitle-prefetch';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps';
|
||||
|
||||
export interface SubtitlePrefetchInitControllerDeps {
|
||||
getCurrentService: () => SubtitlePrefetchService | null;
|
||||
@@ -14,6 +15,7 @@ export interface SubtitlePrefetchInitControllerDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
hasCachedTokenization?: (text: string) => boolean;
|
||||
getCacheGeneration?: () => number;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
||||
@@ -32,11 +34,20 @@ export function createSubtitlePrefetchInitController(
|
||||
deps: SubtitlePrefetchInitControllerDeps,
|
||||
): SubtitlePrefetchInitController {
|
||||
let initRevision = 0;
|
||||
// What the running service was built from. Startup fires several inits for one source
|
||||
// (Jellyfin preload seed, sid change, each track-list update); restarting on each would
|
||||
// discard the in-flight tokenization and re-queue the priority window behind it.
|
||||
let activeSource: { key: string; content: string; cues: SubtitleCue[] } | null = null;
|
||||
|
||||
const stopCurrentService = (): void => {
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
activeSource = null;
|
||||
};
|
||||
|
||||
const cancelPendingInit = (): void => {
|
||||
initRevision += 1;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
stopCurrentService();
|
||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||
};
|
||||
|
||||
@@ -46,14 +57,26 @@ export function createSubtitlePrefetchInitController(
|
||||
sourceKey = sourcePath,
|
||||
): Promise<void> => {
|
||||
const revision = ++initRevision;
|
||||
deps.getCurrentService()?.stop();
|
||||
deps.setCurrentService(null);
|
||||
// The same source may still be current once loaded; keep its service running until then.
|
||||
if (activeSource?.key !== sourceKey) {
|
||||
stopCurrentService();
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await deps.loadSubtitleSourceText(sourcePath);
|
||||
if (revision !== initRevision) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeSource?.key === sourceKey &&
|
||||
activeSource.content === content &&
|
||||
deps.getCurrentService()
|
||||
) {
|
||||
// A track change clears the published cues synchronously, so republish them.
|
||||
deps.onParsedSubtitleCuesChanged?.(activeSource.cues, sourceKey);
|
||||
return;
|
||||
}
|
||||
stopCurrentService();
|
||||
|
||||
const cues = deps.parseSubtitleCues(content, sourcePath);
|
||||
if (revision !== initRevision || cues.length === 0) {
|
||||
@@ -66,11 +89,16 @@ export function createSubtitlePrefetchInitController(
|
||||
return;
|
||||
}
|
||||
|
||||
// Overlap cues only feed the cache; the published cue list stays as authored.
|
||||
const prefetchCues = [...cues, ...buildOverlapPrefetchCues(cues)].sort(
|
||||
(a, b) => a.startTime - b.startTime,
|
||||
);
|
||||
const nextService = deps.createSubtitlePrefetchService({
|
||||
cues,
|
||||
cues: prefetchCues,
|
||||
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
||||
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
||||
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
|
||||
getCacheGeneration: deps.getCacheGeneration,
|
||||
});
|
||||
|
||||
if (revision !== initRevision) {
|
||||
@@ -78,6 +106,7 @@ export function createSubtitlePrefetchInitController(
|
||||
}
|
||||
|
||||
deps.setCurrentService(nextService);
|
||||
activeSource = { key: sourceKey, content, cues };
|
||||
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
|
||||
nextService.start(currentTimePos);
|
||||
deps.logInfo(
|
||||
@@ -85,6 +114,7 @@ export function createSubtitlePrefetchInitController(
|
||||
);
|
||||
} catch (error) {
|
||||
if (revision === initRevision) {
|
||||
stopCurrentService();
|
||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps';
|
||||
|
||||
test('overlapping cues yield the combined line the live path resolves', () => {
|
||||
const cues: SubtitleCue[] = [
|
||||
{ startTime: 0, endTime: 4, text: 'このまま頑張ったって―' },
|
||||
{ startTime: 2, endTime: 6, text: 'ちょっとカズマ\n聞こえてんの?' },
|
||||
];
|
||||
|
||||
assert.deepEqual(buildOverlapPrefetchCues(cues), [
|
||||
{ startTime: 2, endTime: 4, text: 'このまま頑張ったって―\n\nちょっとカズマ\n聞こえてんの?' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('back-to-back cues yield no overlap lines', () => {
|
||||
const cues: SubtitleCue[] = [
|
||||
{ startTime: 0, endTime: 2, text: 'first' },
|
||||
{ startTime: 2, endTime: 4, text: 'second' },
|
||||
];
|
||||
|
||||
assert.deepEqual(buildOverlapPrefetchCues(cues), []);
|
||||
});
|
||||
|
||||
test('each overlap span joins only the cues active across it, in cue-list order', () => {
|
||||
const cues: SubtitleCue[] = [
|
||||
{ startTime: 3, endTime: 8, text: 'C' },
|
||||
{ startTime: 0, endTime: 6, text: 'A' },
|
||||
{ startTime: 2, endTime: 4, text: 'B' },
|
||||
];
|
||||
|
||||
assert.deepEqual(buildOverlapPrefetchCues(cues), [
|
||||
{ startTime: 2, endTime: 3, text: 'A\n\nB' },
|
||||
{ startTime: 3, endTime: 4, text: 'C\n\nA\n\nB' },
|
||||
{ startTime: 4, endTime: 6, text: 'C\n\nA' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
|
||||
|
||||
/**
|
||||
* While cues overlap, mpv publishes their texts joined as one `sub-text`, and the live
|
||||
* path tokenizes and caches that combined line as its own entry. Prefetching single cues
|
||||
* never warms it, so overlapping lines (usually the long ones) always miss the cache.
|
||||
*
|
||||
* Returns one synthetic cue per overlap span carrying the text the live path resolves
|
||||
* for it, so the prefetcher can warm those lines in timeline order with the rest.
|
||||
*/
|
||||
export function buildOverlapPrefetchCues(cues: readonly SubtitleCue[]): SubtitleCue[] {
|
||||
const boundaries = [...new Set(cues.flatMap((cue) => [cue.startTime, cue.endTime]))].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const indices = cues.map((_, index) => index);
|
||||
const byStart = [...indices].sort((a, b) => cues[a]!.startTime - cues[b]!.startTime);
|
||||
const byEnd = [...indices].sort((a, b) => cues[a]!.endTime - cues[b]!.endTime);
|
||||
const singleTexts = new Set(cues.map((cue) => cue.text));
|
||||
const seen = new Set<string>();
|
||||
const overlapCues: SubtitleCue[] = [];
|
||||
|
||||
// Sweep the boundaries once, keeping the cues active over [startTime, endTime). Every
|
||||
// cue edge is a boundary, so a cue is active for a whole interval or not at all.
|
||||
const active = new Set<number>();
|
||||
let nextStart = 0;
|
||||
let nextEnd = 0;
|
||||
for (let i = 0; i + 1 < boundaries.length; i += 1) {
|
||||
const startTime = boundaries[i]!;
|
||||
const endTime = boundaries[i + 1]!;
|
||||
for (; nextEnd < byEnd.length && cues[byEnd[nextEnd]!]!.endTime <= startTime; nextEnd += 1) {
|
||||
active.delete(byEnd[nextEnd]!);
|
||||
}
|
||||
for (
|
||||
;
|
||||
nextStart < byStart.length && cues[byStart[nextStart]!]!.startTime <= startTime;
|
||||
nextStart += 1
|
||||
) {
|
||||
const index = byStart[nextStart]!;
|
||||
// Empty or inverted cues are never on screen.
|
||||
if (cues[index]!.endTime > startTime) active.add(index);
|
||||
}
|
||||
if (active.size < 2) continue;
|
||||
|
||||
// mpv joins simultaneous cues in cue-list order.
|
||||
const liveText = [...active]
|
||||
.sort((a, b) => a - b)
|
||||
.map((index) => cues[index]!.text)
|
||||
.join('\n');
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText,
|
||||
currentTimeSec: (startTime + endTime) / 2,
|
||||
cues,
|
||||
});
|
||||
if (!text.trim() || singleTexts.has(text) || seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
overlapCues.push({ startTime, endTime, text });
|
||||
}
|
||||
|
||||
return overlapCues;
|
||||
}
|
||||
@@ -26,6 +26,8 @@ function buildHandlers(overrides: Partial<TrayTemplateDeps> = {}) {
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => false,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -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;
|
||||
@@ -68,6 +72,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
getDictionaryBackend: () => DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
@@ -109,6 +115,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
deps.openFirstRunSetupWindow(true);
|
||||
},
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
|
||||
dictionaryBackend: deps.getDictionaryBackend(),
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openYomitanSettings: () => {
|
||||
deps.openYomitanSettings();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -58,6 +62,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
getDictionaryBackend: () => DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
@@ -84,6 +90,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,
|
||||
|
||||
@@ -38,6 +38,8 @@ function baseHandlers(overrides: Partial<TrayMenuHandlers> = {}): TrayMenuHandle
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => undefined,
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
@@ -74,6 +76,27 @@ for (const entry of optionalEntries) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
|
||||
test(`tray menu shows only ${dictionaryBackend} settings and dispatches its handler`, () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime(
|
||||
baseHandlers({
|
||||
dictionaryBackend,
|
||||
openHachidoriSettings: () => calls.push('hachidori'),
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
}),
|
||||
);
|
||||
const labels = template.flatMap((entry) => (entry.label ? [entry.label] : []));
|
||||
const [shown, hidden] =
|
||||
dictionaryBackend === 'hachidori'
|
||||
? ['Open Hachidori Settings', 'Open Yomitan Settings']
|
||||
: ['Open Yomitan Settings', 'Open Hachidori Settings'];
|
||||
assert.equal(labels.includes(hidden), false);
|
||||
template.find((entry) => entry.label === shown)?.click?.();
|
||||
assert.deepEqual(calls, [dictionaryBackend]);
|
||||
});
|
||||
}
|
||||
|
||||
test('tray menu ends with a separator and Quit', () => {
|
||||
const template = buildTrayMenuTemplateRuntime(baseHandlers());
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -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;
|
||||
@@ -103,8 +107,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,7 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AnkiConnectConfig, ResolvedConfig } from '../../types';
|
||||
import { createYomitanAnkiServerSyncRuntime } from './yomitan-anki-server-sync';
|
||||
import {
|
||||
buildYomitanAnkiSettingsKey,
|
||||
createYomitanAnkiServerSyncRuntime,
|
||||
} from './yomitan-anki-server-sync';
|
||||
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
|
||||
|
||||
type SyncCall = { url: string; forceOverride: boolean; deck: string };
|
||||
|
||||
@@ -64,3 +68,15 @@ test('retries the same settings after a failed sync', async () => {
|
||||
await harness.sync();
|
||||
assert.equal(harness.calls.length, 2);
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -18,8 +19,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): {
|
||||
@@ -47,6 +49,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
|
||||
targetUrl,
|
||||
targetDeck,
|
||||
forceOverride,
|
||||
hachidoriHints: buildHachidoriAnkiHints(ankiConnectConfig),
|
||||
});
|
||||
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
|
||||
return;
|
||||
@@ -66,6 +69,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 {
|
||||
|
||||
@@ -19,6 +19,44 @@
|
||||
import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
|
||||
import { resolveOverlayLayerFromArgv } from './preload-args';
|
||||
import { createIpcListenerFactories } from './preload-ipc-listeners';
|
||||
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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import { createKeyboardHandlers } from './keyboard.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { HACHIDORI_HOST_SELECTOR } from '../yomitan-popup.js';
|
||||
import type { CompiledSessionBinding } from '../../types';
|
||||
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
|
||||
import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
@@ -152,6 +153,10 @@ function installKeyboardTestGlobals() {
|
||||
let selectionAddCount = 0;
|
||||
|
||||
let popupVisible = false;
|
||||
let hachidoriPaneOpen = false;
|
||||
const hachidoriHost = {
|
||||
shadowRoot: { querySelectorAll: () => [{ hidden: false }] },
|
||||
};
|
||||
|
||||
const popupIframe = {
|
||||
tagName: 'IFRAME',
|
||||
@@ -285,7 +290,10 @@ function installKeyboardTestGlobals() {
|
||||
listeners.push(listener);
|
||||
documentListeners.set(type, listeners);
|
||||
},
|
||||
querySelectorAll: () => {
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (selector === HACHIDORI_HOST_SELECTOR) {
|
||||
return hachidoriPaneOpen ? [hachidoriHost] : [];
|
||||
}
|
||||
if (popupVisible) {
|
||||
return [popupIframe];
|
||||
}
|
||||
@@ -455,6 +463,9 @@ function installKeyboardTestGlobals() {
|
||||
setPopupVisible: (value: boolean) => {
|
||||
popupVisible = value;
|
||||
},
|
||||
setHachidoriPaneOpen: (value: boolean) => {
|
||||
hachidoriPaneOpen = value;
|
||||
},
|
||||
setStatsToggleKey: (value: string) => {
|
||||
statsToggleKey = value;
|
||||
},
|
||||
@@ -475,6 +486,18 @@ function installKeyboardTestGlobals() {
|
||||
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
|
||||
return target;
|
||||
},
|
||||
// Events inside Hachidori's shadow root reach the document retargeted to its host.
|
||||
createDictionaryPopupHostTarget: () => {
|
||||
const target = new TestElement();
|
||||
target.closest = (selector: string) =>
|
||||
selector
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.includes(HACHIDORI_HOST_SELECTOR)
|
||||
? target
|
||||
: null;
|
||||
return target;
|
||||
},
|
||||
createTextInputTarget: () => {
|
||||
const target = new TestElement();
|
||||
target.tagName = 'INPUT';
|
||||
@@ -714,6 +737,23 @@ test('right-clicking interactive overlay controls does not raise playback window
|
||||
}
|
||||
});
|
||||
|
||||
test('right-clicking inside a dictionary popup host does not raise playback window or toggle pause', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
const popupHost = testGlobals.createDictionaryPopupHostTarget();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchDocumentMouseDown({ button: 2, target: popupHost });
|
||||
await wait(0);
|
||||
|
||||
assert.deepEqual(testGlobals.interactionActivations, []);
|
||||
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv input forwarding retries a transient keyboard config IPC failure', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
let calls = 0;
|
||||
@@ -1177,7 +1217,13 @@ test('imported mpv wheel bindings forward from the overlay but not over modals',
|
||||
|
||||
testGlobals.dispatchWheel({ deltaY: 120 });
|
||||
testGlobals.dispatchWheel({ deltaY: -120, target: testGlobals.createInteractiveTarget() });
|
||||
// A Hachidori popup scrolls itself; its events arrive retargeted to its host.
|
||||
const popupScrolled = !testGlobals.dispatchWheel({
|
||||
deltaY: -120,
|
||||
target: testGlobals.createDictionaryPopupHostTarget(),
|
||||
});
|
||||
|
||||
assert.equal(popupScrolled, true);
|
||||
assert.deepEqual(testGlobals.mpvCommands, [['keypress', 'WHEEL_DOWN', 1]]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
@@ -1222,6 +1268,86 @@ test('imported mpv mouse buttons forward only after SubMiner mouse handling', as
|
||||
}
|
||||
});
|
||||
|
||||
test('Hachidori attention without an open popup pane still forwards imported mpv input', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
testGlobals.setGetMpvInputBindings(async () => ({
|
||||
keys: ['0', 'MBTN_LEFT_DBL'],
|
||||
blockedKeys: [],
|
||||
}));
|
||||
await handlers.setupMpvInputForwarding();
|
||||
await wait(0);
|
||||
// Hachidori claims attention for any press that may start a selection.
|
||||
ctx.state.hachidoriReaderSeen = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
|
||||
testGlobals.dispatchKeydown({ key: '0', code: 'Digit0' });
|
||||
testGlobals.dispatchMousedown({ button: 0 });
|
||||
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup' });
|
||||
|
||||
assert.deepEqual(testGlobals.commandEvents, []);
|
||||
assert.deepEqual(testGlobals.mpvCommands, [
|
||||
['keydown', '0'],
|
||||
['keydown', 'MBTN_LEFT'],
|
||||
['keyup', 'MBTN_LEFT'],
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: lookup toggle opens a lookup during Hachidori attention without a pane', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
ctx.state.hachidoriReaderSeen = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
|
||||
assert.deepEqual(
|
||||
testGlobals.commandEvents.filter((event) => event.type === 'setVisible'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: lookup toggle closes an open Hachidori pane after attention is released', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
// A selection drag leaves the pane open while the Yomitan-shaped visible marker clears.
|
||||
ctx.state.hachidoriReaderSeen = true;
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
testGlobals.setHachidoriPaneOpen(true);
|
||||
|
||||
handlers.handleLookupWindowToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
assert.deepEqual(
|
||||
testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||
),
|
||||
[{ type: 'setVisible', visible: false }, { type: 'clearActiveTextSource' }],
|
||||
);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('configured subtitle-jump keybinding preserves pause when pause state is unknown', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import { createMpvInputForwarding } from './mpv-input-forwarding';
|
||||
import { MPV_MOUSE_BUTTON_BY_BUTTON, wheelEventToMpvWheel } from '../../shared/mpv-input-bindings';
|
||||
import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
registerDictionaryPopupVisibilityListener,
|
||||
HACHIDORI_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_COMMAND_EVENT,
|
||||
YOMITAN_POPUP_HOST_SELECTOR,
|
||||
isHachidoriPopupOpen,
|
||||
isLookupPopupOpen,
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
@@ -80,6 +83,8 @@ export function createKeyboardHandlers(
|
||||
if (target.closest('.modal')) return true;
|
||||
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
||||
if (isYomitanPopupIframe(target)) return true;
|
||||
// Hachidori's popup lives in a shadow root, so its events arrive retargeted to the host.
|
||||
if (target.closest(`${YOMITAN_POPUP_HOST_SELECTOR}, ${HACHIDORI_HOST_SELECTOR}`)) return true;
|
||||
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
|
||||
return true;
|
||||
return false;
|
||||
@@ -106,11 +111,13 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
// Overlay UI that handles its own mouse input (menus, sidebar, notifications, controls).
|
||||
// Hachidori's popup lives in the overlay document, unlike Yomitan's iframe, so its
|
||||
// wheel events reach these handlers retargeted to its host.
|
||||
function isOverlayControlTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'.modal, .notification-history, .overlay-notification-stack, button, a, input, select, textarea',
|
||||
`.modal, .notification-history, .overlay-notification-stack, button, a, input, select, textarea, ${YOMITAN_POPUP_HOST_SELECTOR}, ${HACHIDORI_HOST_SELECTOR}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -122,8 +129,7 @@ export function createKeyboardHandlers(
|
||||
!ctx.state.playlistBrowserModalOpen &&
|
||||
!ctx.state.youtubePickerModalOpen &&
|
||||
!ctx.state.subtitleSidebarModalOpen &&
|
||||
!ctx.state.yomitanPopupVisible &&
|
||||
!isYomitanPopupVisible(document) &&
|
||||
!isLookupPopupOpen(ctx.state, document) &&
|
||||
!isInteractiveTarget(target)
|
||||
);
|
||||
}
|
||||
@@ -515,8 +521,7 @@ export function createKeyboardHandlers(
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
const shouldRefreshLookup =
|
||||
pendingLookupRefreshAfterSubtitleSeek &&
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
||||
pendingLookupRefreshAfterSubtitleSeek && isLookupPopupOpen(ctx.state, document);
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
if (shouldRefreshLookup) {
|
||||
queueMicrotask(() => {
|
||||
@@ -708,7 +713,7 @@ export function createKeyboardHandlers(
|
||||
if (lookupScanFallbackTimer !== null) clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = setTimeout(() => {
|
||||
lookupScanFallbackTimer = null;
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (isLookupPopupOpen(ctx.state, document)) {
|
||||
return;
|
||||
}
|
||||
// Dispatch directly on the selected token span; when overlay pointer-events are disabled,
|
||||
@@ -738,15 +743,22 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function handleLookupWindowToggleRequested(): void {
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (isLookupPopupOpen(ctx.state, document)) {
|
||||
closeLookupWindow();
|
||||
return;
|
||||
}
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
|
||||
// Closes any popup state: an open Hachidori pane (its attention may already be
|
||||
// released) and attention without a pane, so callers like the Kiku modal
|
||||
// opener leave nothing behind.
|
||||
function closeLookupWindow(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (
|
||||
!ctx.state.yomitanPopupVisible &&
|
||||
!isYomitanPopupVisible(document) &&
|
||||
!isHachidoriPopupOpen(document)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -770,7 +782,7 @@ export function createKeyboardHandlers(
|
||||
return false;
|
||||
}
|
||||
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
const popupVisible = isLookupPopupOpen(ctx.state, document);
|
||||
const result = moveKeyboardSelection(delta);
|
||||
if (result === 'no-words') {
|
||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||
@@ -791,7 +803,7 @@ export function createKeyboardHandlers(
|
||||
code: string,
|
||||
repeat: boolean = true,
|
||||
): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupKeydown(key, code, [], repeat);
|
||||
@@ -799,7 +811,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function mineSelectedFromController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupMineSelected();
|
||||
@@ -807,7 +819,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function cyclePopupAudioSourceForController(direction: -1 | 1): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupCycleAudioSource(direction);
|
||||
@@ -815,7 +827,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function playCurrentAudioForController(): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupPlayCurrentAudio();
|
||||
@@ -823,7 +835,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function scrollPopupByController(deltaX: number, deltaY: number): boolean {
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return false;
|
||||
}
|
||||
dispatchYomitanPopupScrollBy(deltaX, deltaY);
|
||||
@@ -850,7 +862,7 @@ export function createKeyboardHandlers(
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||
if (!isLookupPopupOpen(ctx.state, document)) {
|
||||
return;
|
||||
}
|
||||
restoreOverlayKeyboardFocus();
|
||||
@@ -895,7 +907,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
const key = e.code;
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
const popupVisible = isLookupPopupOpen(ctx.state, document);
|
||||
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary' || result === 'no-words') {
|
||||
@@ -1086,7 +1098,7 @@ export function createKeyboardHandlers(
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
registerDictionaryPopupVisibilityListener('hidden', () => {
|
||||
clearNativeSubtitleSelection();
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
syncKeyboardTokenSelection();
|
||||
@@ -1094,7 +1106,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
restoreOverlayKeyboardFocus();
|
||||
});
|
||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||
registerDictionaryPopupVisibilityListener('shown', () => {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
return;
|
||||
}
|
||||
@@ -1251,7 +1263,7 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (isLookupPopupOpen(ctx.state, document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,11 @@ import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subti
|
||||
import { createRendererState } from '../state';
|
||||
import { createMouseHandlers } from './mouse.js';
|
||||
import {
|
||||
HACHIDORI_HOST_SELECTOR,
|
||||
HACHIDORI_POPUP_HIDDEN_EVENT,
|
||||
HACHIDORI_POPUP_SELECTOR,
|
||||
HACHIDORI_POPUP_SHOWN_EVENT,
|
||||
YOMITAN_LOOKUP_EVENT,
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||
@@ -50,6 +55,8 @@ const MOCKED_GLOBALS = ['window', 'document', 'MutationObserver', 'Node'] as con
|
||||
* Installs window/document/MutationObserver/Node stubs (restored via `t.after`) and builds
|
||||
* mouse handlers with no-op defaults. `hoverPause`, `popupPause`, `hovered` and
|
||||
* `popupHostVisible` are mutable mid-test; `fire` dispatches to the registered listeners.
|
||||
* With `hachidori`, the popup host is a Hachidori shadow host: `popupHostVisible` is its
|
||||
* attention flag, `popupPaneOpen` its popup pane, and `popupHostAttached` its presence.
|
||||
*/
|
||||
function createMouseHarness(
|
||||
t: TestContext,
|
||||
@@ -61,6 +68,7 @@ function createMouseHarness(
|
||||
getPlaybackPaused?: () => Promise<boolean | null>;
|
||||
popupHostVisible?: boolean;
|
||||
hovered?: HoverTarget;
|
||||
hachidori?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const overlayClassList = createClassList();
|
||||
@@ -102,11 +110,23 @@ function createMouseHarness(
|
||||
};
|
||||
const ignoreCalls: IgnoreCall[] = [];
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const popupHost = {
|
||||
tagName: 'DIV',
|
||||
getAttribute: (name: string) =>
|
||||
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
||||
const popupPane = {
|
||||
get hidden() {
|
||||
return !harness.popupPaneOpen;
|
||||
},
|
||||
};
|
||||
const popupHost = {
|
||||
tagName: options.hachidori ? 'HACHIDORI-HOST' : 'DIV',
|
||||
getAttribute: (name: string) =>
|
||||
name === 'data-subminer-yomitan-popup-visible' ? String(harness.popupHostVisible) : null,
|
||||
shadowRoot: {
|
||||
querySelectorAll: (selector: string) =>
|
||||
selector === HACHIDORI_POPUP_SELECTOR ? [popupPane] : [],
|
||||
},
|
||||
};
|
||||
const hostSelectors = options.hachidori
|
||||
? [HACHIDORI_HOST_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]
|
||||
: [YOMITAN_POPUP_HOST_SELECTOR];
|
||||
|
||||
const harness = {
|
||||
ctx,
|
||||
@@ -118,6 +138,8 @@ function createMouseHarness(
|
||||
popupPause: options.popupPause ?? false,
|
||||
hovered: options.hovered ?? null,
|
||||
popupHostVisible: options.popupHostVisible ?? false,
|
||||
popupHostAttached: options.hachidori ?? false,
|
||||
popupPaneOpen: false,
|
||||
handlers: null as unknown as ReturnType<typeof createMouseHandlers>,
|
||||
fire: (target: keyof typeof listeners, type: string, event: unknown = {}) => {
|
||||
for (const listener of listeners[target].get(type) ?? []) listener(event);
|
||||
@@ -157,12 +179,15 @@ function createMouseHarness(
|
||||
body: { classList: bodyClassList },
|
||||
addEventListener: addListener('document'),
|
||||
querySelector: () => null,
|
||||
querySelectorAll: (selector: string) =>
|
||||
harness.popupHostVisible &&
|
||||
(selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
||||
selector === YOMITAN_POPUP_HOST_SELECTOR)
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
|
||||
return harness.popupHostVisible ? [popupHost] : [];
|
||||
}
|
||||
return hostSelectors.includes(selector) &&
|
||||
(harness.popupHostVisible || harness.popupHostAttached)
|
||||
? [popupHost]
|
||||
: [],
|
||||
: [];
|
||||
},
|
||||
elementFromPoint: () =>
|
||||
harness.hovered === 'primary'
|
||||
? ctx.dom.subtitleContainer
|
||||
@@ -375,6 +400,72 @@ for (const c of [
|
||||
});
|
||||
}
|
||||
|
||||
// Hachidori publishes one attention signal for an open popup and for a press on
|
||||
// subtitle text that may become a selection, so its popup pane is the source of
|
||||
// truth for what is on screen.
|
||||
|
||||
test('Hachidori press on subtitle text does not pause before a popup opens', async (t) => {
|
||||
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
h.popupHostVisible = true;
|
||||
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
h.popupHostVisible = false;
|
||||
h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT);
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(h.mpvCommands, []);
|
||||
});
|
||||
|
||||
test('Hachidori press before any lookup does not pause while its host is unattached', async (t) => {
|
||||
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
// Hachidori attaches its host on the first lookup, so a press anywhere on
|
||||
// the overlay before that claims attention with nothing in the DOM.
|
||||
h.popupHostAttached = false;
|
||||
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT);
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(h.mpvCommands, []);
|
||||
});
|
||||
|
||||
test('Hachidori selection lookup pauses once its popup opens and resumes on close', async (t) => {
|
||||
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
h.popupHostVisible = true;
|
||||
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
assert.deepEqual(h.mpvCommands, []);
|
||||
|
||||
// The drag's attention carries over to its lookup, so no second shown event arrives.
|
||||
h.popupPaneOpen = true;
|
||||
h.fire('window', YOMITAN_LOOKUP_EVENT);
|
||||
await waitForNextTick();
|
||||
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
||||
|
||||
h.popupPaneOpen = false;
|
||||
h.popupHostVisible = false;
|
||||
h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT);
|
||||
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
|
||||
});
|
||||
|
||||
test('Hachidori hover popup pauses when shown', async (t) => {
|
||||
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
h.popupPaneOpen = true;
|
||||
h.popupHostVisible = true;
|
||||
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
||||
});
|
||||
|
||||
test('popup shown reclaims overlay focus on macOS and captures click-away', (t) => {
|
||||
const h = createMouseHarness(t, { platform: 'macos' });
|
||||
h.handlers.setupYomitanObserver();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
registerDictionaryPopupVisibilityListener,
|
||||
registerYomitanLookupListener,
|
||||
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
|
||||
isHachidoriPopupOpen,
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
@@ -257,8 +258,20 @@ export function createMouseHandlers(
|
||||
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||
}
|
||||
|
||||
// Hachidori also claims attention for a left press anywhere on the overlay
|
||||
// that may become a selection, so once its events identify the reader only
|
||||
// an open popup pane should pause playback.
|
||||
function canPauseForYomitanPopup(): boolean {
|
||||
return (
|
||||
yomitanPopupVisible &&
|
||||
options.getYomitanPopupAutoPauseEnabled() &&
|
||||
(!ctx.state.hachidoriReaderSeen ||
|
||||
(typeof document !== 'undefined' && isHachidoriPopupOpen(document)))
|
||||
);
|
||||
}
|
||||
|
||||
async function maybePauseForYomitanPopup(): Promise<void> {
|
||||
if (!yomitanPopupVisible || !options.getYomitanPopupAutoPauseEnabled()) {
|
||||
if (!canPauseForYomitanPopup()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,11 +291,7 @@ export function createMouseHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
requestId !== popupPauseRequestId ||
|
||||
!yomitanPopupVisible ||
|
||||
!options.getYomitanPopupAutoPauseEnabled()
|
||||
) {
|
||||
if (requestId !== popupPauseRequestId || !canPauseForYomitanPopup()) {
|
||||
return;
|
||||
}
|
||||
if (paused !== false) return;
|
||||
@@ -470,7 +479,8 @@ export function createMouseHandlers(
|
||||
function setupYomitanObserver(): void {
|
||||
reconcilePopupInteraction({ allowPause: true });
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||
registerDictionaryPopupVisibilityListener('shown', (reader) => {
|
||||
if (reader === 'hachidori') ctx.state.hachidoriReaderSeen = true;
|
||||
reconcilePopupInteraction({
|
||||
assumeVisible: true,
|
||||
allowPause: true,
|
||||
@@ -478,10 +488,16 @@ export function createMouseHandlers(
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
registerDictionaryPopupVisibilityListener('hidden', () => {
|
||||
disablePopupInteractionIfIdle();
|
||||
});
|
||||
|
||||
// A Hachidori selection opens its popup while the drag's attention claim is
|
||||
// still held, so no second shown event arrives; its lookup marks the open.
|
||||
registerYomitanLookupListener(window, () => {
|
||||
reconcilePopupInteraction({ allowPause: true });
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
||||
ctx.state.isOverYomitanPopup = true;
|
||||
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
||||
|
||||
@@ -403,7 +403,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' },
|
||||
@@ -412,7 +412,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' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
hasSubtitleSidebarSelection,
|
||||
} from './subtitle-sidebar-selection.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
registerDictionaryPopupVisibilityListener,
|
||||
isYomitanPopupVisible,
|
||||
} from '../yomitan-popup.js';
|
||||
|
||||
@@ -850,12 +849,18 @@ export function createSubtitleSidebarModal(
|
||||
syncEmbeddedSidebarLayout();
|
||||
};
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
|
||||
const disposeShown = registerDictionaryPopupVisibilityListener(
|
||||
'shown',
|
||||
handleYomitanPopupShown,
|
||||
);
|
||||
const disposeHidden = registerDictionaryPopupVisibilityListener(
|
||||
'hidden',
|
||||
handleYomitanPopupHidden,
|
||||
);
|
||||
disposeDomEvents = () => {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
window.removeEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
|
||||
window.removeEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
|
||||
disposeShown();
|
||||
disposeHidden();
|
||||
disposeDomEvents = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
|
||||
|
||||
assert.equal(state.youtubePickerModalOpen, false);
|
||||
assert.deepEqual(notifications, ['youtube-track-picker']);
|
||||
assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]);
|
||||
assert.deepEqual(frontendCommands, []);
|
||||
assert.equal(overlay.classList.contains('interactive'), false);
|
||||
assert.equal(overlayFocused, true);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: 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');
|
||||
@@ -191,13 +190,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');
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ import { createOverlayNotificationHistoryPanel } from './overlay-notification-hi
|
||||
import { createRendererState } from './state.js';
|
||||
import { createModalRegistry, type ModalDescriptor } from './modal-registry.js';
|
||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
||||
import { isLookupPopupOpen, registerYomitanLookupListener } from './yomitan-popup.js';
|
||||
import {
|
||||
createRendererRecoveryController,
|
||||
registerRendererGlobalErrorHandlers,
|
||||
@@ -457,7 +457,7 @@ function startControllerPolling(): void {
|
||||
profiles: {},
|
||||
},
|
||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
||||
getLookupWindowOpen: () => isLookupPopupOpen(ctx.state, document),
|
||||
getInteractionBlocked: () => isControllerInputBlocked(),
|
||||
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
|
||||
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
|
||||
|
||||
@@ -161,6 +161,10 @@ export type RendererState = {
|
||||
keyboardSelectionVisible: boolean;
|
||||
keyboardSelectedWordIndex: number | null;
|
||||
yomitanPopupVisible: boolean;
|
||||
// Set once Hachidori's attention events identify it as the reader. Its attention
|
||||
// also covers presses that may start a selection, so yomitanPopupVisible alone
|
||||
// does not mean a popup is open.
|
||||
hachidoriReaderSeen: boolean;
|
||||
isOverYomitanPopup: boolean;
|
||||
primarySubtitleMode: PrimarySubMode;
|
||||
};
|
||||
@@ -296,6 +300,7 @@ export function createRendererState(): RendererState {
|
||||
keyboardSelectionVisible: false,
|
||||
keyboardSelectedWordIndex: null,
|
||||
yomitanPopupVisible: false,
|
||||
hachidoriReaderSeen: false,
|
||||
isOverYomitanPopup: false,
|
||||
primarySubtitleMode: 'visible',
|
||||
};
|
||||
|
||||
@@ -7,11 +7,36 @@ import {
|
||||
hasYomitanPopupIframe,
|
||||
isYomitanPopupIframe,
|
||||
isYomitanPopupVisible,
|
||||
registerDictionaryPopupVisibilityListener,
|
||||
} from './yomitan-popup.js';
|
||||
|
||||
type FakeStyle = Pick<CSSStyleDeclaration, 'visibility' | 'display' | 'opacity'>;
|
||||
type FakeFrame = Element & { fakeStyle: FakeStyle };
|
||||
|
||||
test('native popup attention events from either backend have the same lifecycle', () => {
|
||||
for (const backend of ['yomitan', 'hachidori']) {
|
||||
const target = new EventTarget();
|
||||
const calls: string[] = [];
|
||||
const disposeShown = registerDictionaryPopupVisibilityListener(
|
||||
'shown',
|
||||
() => calls.push('shown'),
|
||||
target,
|
||||
);
|
||||
const disposeHidden = registerDictionaryPopupVisibilityListener(
|
||||
'hidden',
|
||||
() => calls.push('hidden'),
|
||||
target,
|
||||
);
|
||||
target.dispatchEvent(new CustomEvent(`${backend}-popup-shown`));
|
||||
target.dispatchEvent(new CustomEvent(`${backend}-popup-hidden`));
|
||||
disposeShown();
|
||||
disposeHidden();
|
||||
target.dispatchEvent(new CustomEvent(`${backend}-popup-shown`));
|
||||
target.dispatchEvent(new CustomEvent(`${backend}-popup-hidden`));
|
||||
assert.deepEqual(calls, ['shown', 'hidden']);
|
||||
}
|
||||
});
|
||||
|
||||
const VISIBLE_STYLE: FakeStyle = { visibility: 'visible', display: 'block', opacity: '1' };
|
||||
|
||||
function frame(options: { width?: number; height?: number; style?: Partial<FakeStyle> } = {}) {
|
||||
|
||||
@@ -10,6 +10,41 @@ export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
|
||||
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||
export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup';
|
||||
export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup';
|
||||
// Hachidori's shown/hidden pair means "the reader needs mouse events", which
|
||||
// also covers a left press on subtitle text that may start a selection. Its
|
||||
// popup panes live in the host's open shadow root.
|
||||
export const HACHIDORI_POPUP_SHOWN_EVENT = 'hachidori-popup-shown';
|
||||
export const HACHIDORI_POPUP_HIDDEN_EVENT = 'hachidori-popup-hidden';
|
||||
export const HACHIDORI_HOST_SELECTOR = 'hachidori-host';
|
||||
export const HACHIDORI_POPUP_SELECTOR = '.gsm-hoshidicts-popup';
|
||||
|
||||
export type DictionaryReader = 'yomitan' | 'hachidori';
|
||||
|
||||
// Only the active backend injects a reader. Consume its native attention events.
|
||||
export function registerDictionaryPopupVisibilityListener(
|
||||
state: 'shown' | 'hidden',
|
||||
listener: (reader: DictionaryReader) => void,
|
||||
target: EventTarget = window,
|
||||
): () => void {
|
||||
const events: Array<[string, DictionaryReader]> =
|
||||
state === 'shown'
|
||||
? [
|
||||
[YOMITAN_POPUP_SHOWN_EVENT, 'yomitan'],
|
||||
[HACHIDORI_POPUP_SHOWN_EVENT, 'hachidori'],
|
||||
]
|
||||
: [
|
||||
[YOMITAN_POPUP_HIDDEN_EVENT, 'yomitan'],
|
||||
[HACHIDORI_POPUP_HIDDEN_EVENT, 'hachidori'],
|
||||
];
|
||||
const wrapped = events.map(([event, reader]) => {
|
||||
const handler = (): void => listener(reader);
|
||||
target.addEventListener(event, handler);
|
||||
return [event, handler] as const;
|
||||
});
|
||||
return () => {
|
||||
for (const [event, handler] of wrapped) target.removeEventListener(event, handler);
|
||||
};
|
||||
}
|
||||
|
||||
export function registerYomitanLookupListener(
|
||||
target: EventTarget = window,
|
||||
@@ -73,6 +108,33 @@ function queryPopupElements<T extends Element>(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Hachidori popup pane is on screen. Auto-pause reads this because
|
||||
* the host's visible marker only tracks Hachidori's attention signal. Hachidori
|
||||
* attaches its host on the first lookup, so no host means no popup.
|
||||
*/
|
||||
export function isHachidoriPopupOpen(root: ParentNode | null | undefined = document): boolean {
|
||||
const hosts = queryPopupElements<HTMLElement>(root, HACHIDORI_HOST_SELECTOR);
|
||||
return hosts.some((host) =>
|
||||
queryPopupElements<HTMLElement>(host.shadowRoot, HACHIDORI_POPUP_SELECTOR).some(
|
||||
(pane) => !pane.hidden,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a lookup popup is open, for input that should go to it instead of mpv.
|
||||
* Once Hachidori is the reader only an open pane counts, because its attention
|
||||
* (the shown event and the host's visible marker) also covers a plain press.
|
||||
*/
|
||||
export function isLookupPopupOpen(
|
||||
state: { yomitanPopupVisible: boolean; hachidoriReaderSeen: boolean },
|
||||
root: ParentNode | null | undefined = document,
|
||||
): boolean {
|
||||
if (state.hachidoriReaderSeen) return isHachidoriPopupOpen(root);
|
||||
return state.yomitanPopupVisible || isYomitanPopupVisible(root);
|
||||
}
|
||||
|
||||
export function isYomitanPopupVisible(root: ParentNode | null | undefined = document): boolean {
|
||||
const visiblePopupHosts = queryPopupElements<HTMLElement>(
|
||||
root,
|
||||
|
||||
@@ -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,2 @@
|
||||
// Stats cards already own their sentence and media, including while polling Anki directly.
|
||||
export const STATS_MINING_TAG = 'SubMiner::Stats';
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseDictionaryExternalUrl } from './dictionary-external-link';
|
||||
|
||||
test('dictionary links accept web URLs and reject privileged schemes and credentials', () => {
|
||||
assert.equal(
|
||||
parseDictionaryExternalUrl('https://example.com/word?q=猫'),
|
||||
'https://example.com/word?q=%E7%8C%AB',
|
||||
);
|
||||
for (const value of [
|
||||
'file:///etc/passwd',
|
||||
'javascript:alert(1)',
|
||||
'https://user:pass@example.com',
|
||||
null,
|
||||
{},
|
||||
]) {
|
||||
assert.throws(() => parseDictionaryExternalUrl(value));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export const DICTIONARY_EXTERNAL_LINK_CHANNEL = 'dictionary:open-external';
|
||||
|
||||
export function parseDictionaryExternalUrl(value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error('Expected a link URL');
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||
throw new Error('Dictionary links must use HTTP or HTTPS without credentials');
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildHachidoriSharingScript, parseHachidoriHostStatus } from './hachidori-sharing';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
|
||||
test('host status rejects malformed replies and distinguishes local, connected and offline', () => {
|
||||
assert.deepEqual(parseHachidoriHostStatus({ ok: true, sharing: { client: { linked: false } } }), {
|
||||
kind: 'local',
|
||||
});
|
||||
assert.throws(
|
||||
() => parseHachidoriHostStatus({ ok: false, error: 'Connection refused' }),
|
||||
/Connection refused/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseHachidoriHostStatus({
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: { linked: true, connected: true, address: 'host', host: { dictionaryCount: -1 } },
|
||||
},
|
||||
}),
|
||||
/dictionary count/,
|
||||
);
|
||||
assert.equal(
|
||||
parseHachidoriHostStatus({
|
||||
ok: true,
|
||||
sharing: { client: { linked: true, address: 'host', connected: false } },
|
||||
}).kind,
|
||||
'disconnected',
|
||||
);
|
||||
});
|
||||
|
||||
test('sharing script safely links an address and checks live engine inventory', async () => {
|
||||
const address = `ws://host:8771/link?quote="`;
|
||||
const requests: Array<Record<string, unknown>> = [];
|
||||
const script = buildHachidoriSharingScript({ type: 'hd_sharing_client_link', address });
|
||||
const value: unknown = await runInNewContext(script, {
|
||||
crypto,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage: async (message: Record<string, unknown>) => {
|
||||
requests.push(message);
|
||||
if (message.type === 'hd_status') return { ok: true, ready: true, loading: false };
|
||||
if (message.type === 'hd_state_read')
|
||||
return { ok: true, state: { dictionaries: [{}, {}] } };
|
||||
return {
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: {
|
||||
linked: true,
|
||||
connected: true,
|
||||
address,
|
||||
host: { name: 'Host', dictionaryCount: 99, capabilities: ['linked-import-v1', 7] },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(requests[0]?.address, address);
|
||||
assert.deepEqual(parseHachidoriHostStatus(value), {
|
||||
kind: 'connected',
|
||||
address,
|
||||
name: 'Host',
|
||||
dictionaryCount: 2,
|
||||
capabilities: ['linked-import-v1'],
|
||||
});
|
||||
});
|
||||
|
||||
test('an unresponsive host preserves its address so setup can still unlink', async () => {
|
||||
const value: unknown = await runInNewContext(
|
||||
buildHachidoriSharingScript({ type: 'hd_sharing_status' }),
|
||||
{
|
||||
crypto,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage: async (message: { type: string }) => {
|
||||
if (message.type === 'hd_status') throw new Error('Host stopped responding');
|
||||
return {
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: {
|
||||
linked: true,
|
||||
connected: true,
|
||||
address: 'ws://host:8771/link',
|
||||
host: { dictionaryCount: 1 },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parseHachidoriHostStatus(value), {
|
||||
kind: 'disconnected',
|
||||
address: 'ws://host:8771/link',
|
||||
message: 'Host stopped responding',
|
||||
});
|
||||
});
|
||||
|
||||
for (const stalledCall of ['initial', 'refresh']) {
|
||||
test(`sharing ${stalledCall} requests time out and clear their timers`, async () => {
|
||||
const timers = new Map<number, () => void>();
|
||||
let nextTimer = 0;
|
||||
let sharingCalls = 0;
|
||||
let stalled = false;
|
||||
const result: Promise<unknown> = runInNewContext(
|
||||
buildHachidoriSharingScript({ type: 'hd_sharing_status' }),
|
||||
{
|
||||
crypto,
|
||||
setTimeout: (callback: () => void, delay: number) => {
|
||||
assert.equal(delay, 5000);
|
||||
timers.set(++nextTimer, callback);
|
||||
return nextTimer;
|
||||
},
|
||||
clearTimeout: (id: number) => timers.delete(id),
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage: async (message: { type: string }) => {
|
||||
if (message.type === 'hd_status') return { ok: true, ready: true };
|
||||
sharingCalls += 1;
|
||||
if (sharingCalls === (stalledCall === 'initial' ? 1 : 2)) {
|
||||
stalled = true;
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: {
|
||||
linked: true,
|
||||
connected: true,
|
||||
address: 'host',
|
||||
host: { dictionaryCount: 1 },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
for (let i = 0; i < 20 && !stalled; i++) await Promise.resolve();
|
||||
assert.equal(stalled, true);
|
||||
assert.equal(timers.size, 1);
|
||||
for (const callback of timers.values()) callback();
|
||||
if (stalledCall === 'initial') {
|
||||
await assert.rejects(result, /host did not respond/);
|
||||
} else {
|
||||
const status = parseHachidoriHostStatus(await result);
|
||||
assert.equal(status.kind, 'disconnected');
|
||||
if (status.kind === 'disconnected') assert.match(status.message, /host did not respond/);
|
||||
}
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export type HachidoriHostStatus =
|
||||
| { kind: 'local' }
|
||||
| {
|
||||
kind: 'connected';
|
||||
address: string;
|
||||
name: string;
|
||||
dictionaryCount: number;
|
||||
// Advertised by the host in its sharing hello, e.g. HACHIDORI_LINKED_IMPORT_CAPABILITY.
|
||||
capabilities: readonly string[];
|
||||
}
|
||||
| { kind: 'disconnected'; address: string; message: string }
|
||||
| { kind: 'unavailable'; message: string };
|
||||
|
||||
// Hosts with this capability (Hachidori 0.2.3+) accept dictionary ZIPs over the link.
|
||||
export const HACHIDORI_LINKED_IMPORT_CAPABILITY = 'linked-import-v1';
|
||||
|
||||
export type HachidoriSharingRequest =
|
||||
| { type: 'hd_sharing_status' }
|
||||
| { type: 'hd_sharing_client_link'; address: string }
|
||||
| { type: 'hd_sharing_client_unlink' };
|
||||
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseHachidoriHostStatus(reply: unknown): HachidoriHostStatus {
|
||||
if (!object(reply) || reply.ok !== true || !object(reply.sharing)) {
|
||||
throw new Error(
|
||||
object(reply) && typeof reply.error === 'string'
|
||||
? reply.error
|
||||
: 'Hachidori returned an invalid sharing status.',
|
||||
);
|
||||
}
|
||||
const client = reply.sharing.client;
|
||||
if (!object(client) || typeof client.linked !== 'boolean') {
|
||||
throw new Error('Hachidori returned an invalid host connection.');
|
||||
}
|
||||
if (!client.linked) return { kind: 'local' };
|
||||
if (typeof client.address !== 'string') throw new Error('Missing Hachidori host address.');
|
||||
if (client.connected !== true) {
|
||||
return {
|
||||
kind: 'disconnected',
|
||||
address: client.address,
|
||||
message:
|
||||
typeof client.error === 'string'
|
||||
? client.error
|
||||
: 'The dictionary host is not reachable. Start it, then refresh status.',
|
||||
};
|
||||
}
|
||||
const host = client.host;
|
||||
if (
|
||||
!object(host) ||
|
||||
typeof host.dictionaryCount !== 'number' ||
|
||||
!Number.isSafeInteger(host.dictionaryCount) ||
|
||||
host.dictionaryCount < 0
|
||||
) {
|
||||
throw new Error('Hachidori returned an invalid dictionary count.');
|
||||
}
|
||||
return {
|
||||
kind: 'connected',
|
||||
address: client.address,
|
||||
name: typeof host.name === 'string' ? host.name : 'Hachidori',
|
||||
dictionaryCount: host.dictionaryCount,
|
||||
capabilities: Array.isArray(host.capabilities)
|
||||
? host.capabilities.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHachidoriSharingScript(request: HachidoriSharingRequest): string {
|
||||
return `(async () => {
|
||||
const request = ${JSON.stringify(request)};
|
||||
const requestWithTimeout = async fields => {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
chrome.runtime.sendMessage({ requestId: crypto.randomUUID(), ...fields }),
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('The dictionary host did not respond. Check the host and refresh status.')), 5000); }),
|
||||
]);
|
||||
} finally { clearTimeout(timer); }
|
||||
};
|
||||
const send = fields => requestWithTimeout({ target: 'hachidori-sharing', ...fields });
|
||||
const read = (target, type) => requestWithTimeout({ target, type });
|
||||
let reply = await send(request);
|
||||
if (!reply?.ok) throw new Error(reply?.error || 'Hachidori could not update the host connection.');
|
||||
if (reply.sharing?.client?.linked && (reply.sharing.client.connected || request.type === 'hd_sharing_client_link')) {
|
||||
try {
|
||||
const engine = await read('hoshidicts-offscreen', 'hd_status');
|
||||
reply = await send({type: 'hd_sharing_status'});
|
||||
if (engine?.ok && engine.ready && !engine.loading && reply.sharing?.client?.connected) {
|
||||
const inventory = await read('hoshidicts-worker', 'hd_state_read');
|
||||
if (!inventory?.ok || !Array.isArray(inventory.state?.dictionaries)) throw new Error('Could not read the dictionary host library.');
|
||||
reply.sharing.client.host.dictionaryCount = inventory.state.dictionaries.length;
|
||||
} else if (reply.sharing?.client?.linked) {
|
||||
reply.sharing.client.connected = false;
|
||||
reply.sharing.client.error = engine?.loading ? 'The dictionary host is loading. Refresh status when it is ready.' : (engine?.error || 'The dictionary host is not ready.');
|
||||
}
|
||||
} catch (error) {
|
||||
reply.sharing.client.connected = false;
|
||||
reply.sharing.client.error = error.message;
|
||||
}
|
||||
}
|
||||
return reply;
|
||||
})()`;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getDefaultConfigDir,
|
||||
getDefaultConfigFilePaths,
|
||||
getSetupStatePath,
|
||||
isSetupCompleted,
|
||||
normalizeSetupState,
|
||||
readSetupState,
|
||||
resolveDefaultMpvInstallPaths,
|
||||
@@ -312,3 +313,42 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test('setup state keeps backend identity and reads legacy completion as Yomitan', () => {
|
||||
const legacy = { ...createDefaultSetupState(), status: 'completed' as const };
|
||||
assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'yomitan'), true);
|
||||
assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'hachidori'), false);
|
||||
const hachidori = normalizeSetupState({ ...legacy, dictionaryBackend: 'hachidori' });
|
||||
assert.equal(hachidori?.dictionaryBackend, 'hachidori');
|
||||
assert.equal(isSetupCompleted(hachidori, 'hachidori'), true);
|
||||
assert.equal(isSetupCompleted(hachidori, 'yomitan'), false);
|
||||
assert.equal(normalizeSetupState({ ...legacy, dictionaryBackend: 'unknown' }), null);
|
||||
});
|
||||
|
||||
test('setup state remembers every backend that finished setup', () => {
|
||||
const state = normalizeSetupState({
|
||||
...createDefaultSetupState(),
|
||||
status: 'completed',
|
||||
dictionaryBackend: 'hachidori',
|
||||
completedDictionaryBackends: ['yomitan', 'yomitan', 'bogus'],
|
||||
});
|
||||
assert.deepEqual(state?.completedDictionaryBackends, ['yomitan']);
|
||||
assert.equal(isSetupCompleted(state, 'yomitan'), true);
|
||||
assert.equal(isSetupCompleted(state, 'hachidori'), true);
|
||||
const reopened = normalizeSetupState({ ...state, status: 'incomplete' });
|
||||
assert.equal(isSetupCompleted(reopened, 'hachidori'), false);
|
||||
assert.equal(isSetupCompleted(reopened, 'yomitan'), true);
|
||||
for (const status of ['incomplete', 'in_progress', 'cancelled']) {
|
||||
const staleHistory = normalizeSetupState({
|
||||
...state,
|
||||
status,
|
||||
completedDictionaryBackends: ['yomitan', 'hachidori'],
|
||||
});
|
||||
assert.equal(isSetupCompleted(staleHistory, 'hachidori'), false);
|
||||
assert.equal(isSetupCompleted(staleHistory, 'yomitan'), true);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeSetupState({ ...state, completedDictionaryBackends: [] })?.completedDictionaryBackends,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import type { DictionaryBackend } from '../types/config';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { resolveConfigDir } from '../config/path-resolution';
|
||||
@@ -18,6 +19,10 @@ export interface SetupWindowsMpvShortcutPreferences {
|
||||
|
||||
export interface SetupState {
|
||||
version: 4;
|
||||
/** Backend the recorded status belongs to. Missing in legacy state files, which belong to Yomitan. */
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
/** Backends whose setup finished at least once, so switching back never repeats setup. */
|
||||
completedDictionaryBackends?: DictionaryBackend[];
|
||||
status: SetupStateStatus;
|
||||
completedAt: string | null;
|
||||
completionSource: SetupCompletionSource;
|
||||
@@ -51,6 +56,10 @@ function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
function isDictionaryBackend(value: unknown): value is DictionaryBackend {
|
||||
return value === 'yomitan' || value === 'hachidori';
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -93,6 +102,7 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
|
||||
if (
|
||||
(version !== 1 && version !== 2 && version !== 3 && version !== 4) ||
|
||||
(record.dictionaryBackend !== undefined && !isDictionaryBackend(record.dictionaryBackend)) ||
|
||||
(status !== 'incomplete' &&
|
||||
status !== 'in_progress' &&
|
||||
status !== 'completed' &&
|
||||
@@ -127,8 +137,18 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedDictionaryBackends = Array.isArray(record.completedDictionaryBackends)
|
||||
? record.completedDictionaryBackends.filter(isDictionaryBackend)
|
||||
: [];
|
||||
|
||||
return {
|
||||
version: 4,
|
||||
...(isDictionaryBackend(record.dictionaryBackend)
|
||||
? { dictionaryBackend: record.dictionaryBackend }
|
||||
: {}),
|
||||
...(completedDictionaryBackends.length > 0
|
||||
? { completedDictionaryBackends: [...new Set(completedDictionaryBackends)] }
|
||||
: {}),
|
||||
status,
|
||||
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
|
||||
completionSource,
|
||||
@@ -191,8 +211,28 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function isSetupCompleted(state: SetupState | null | undefined): boolean {
|
||||
return state?.status === 'completed';
|
||||
export function getSetupStateDictionaryBackend(state: SetupState): DictionaryBackend {
|
||||
return state.dictionaryBackend ?? 'yomitan';
|
||||
}
|
||||
|
||||
/** Current status takes precedence; completion history applies only to other backends. */
|
||||
export function hasCompletedSetupForBackend(
|
||||
state: SetupState,
|
||||
dictionaryBackend: DictionaryBackend,
|
||||
): boolean {
|
||||
if (getSetupStateDictionaryBackend(state) === dictionaryBackend) {
|
||||
return state.status === 'completed';
|
||||
}
|
||||
return (state.completedDictionaryBackends ?? []).includes(dictionaryBackend);
|
||||
}
|
||||
|
||||
export function isSetupCompleted(
|
||||
state: SetupState | null | undefined,
|
||||
dictionaryBackend?: DictionaryBackend,
|
||||
): boolean {
|
||||
if (!state) return false;
|
||||
if (dictionaryBackend === undefined) return state.status === 'completed';
|
||||
return hasCompletedSetupForBackend(state, dictionaryBackend);
|
||||
}
|
||||
|
||||
export function getDefaultConfigDir(options?: {
|
||||
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user