mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
Overlay 2.0 (#12)
This commit is contained in:
@@ -132,16 +132,15 @@ export function createAnilistTokenStore(
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
if (
|
||||
typeof parsed.plaintextToken === 'string' &&
|
||||
parsed.plaintextToken.trim().length > 0
|
||||
) {
|
||||
if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) {
|
||||
if (storage.isEncryptionAvailable()) {
|
||||
if (!isSafeStorageUsable()) {
|
||||
return null;
|
||||
}
|
||||
const plaintext = parsed.plaintextToken.trim();
|
||||
notifyUser('AniList token plaintext fallback payload found. Migrating to encrypted storage.');
|
||||
notifyUser(
|
||||
'AniList token plaintext fallback payload found. Migrating to encrypted storage.',
|
||||
);
|
||||
this.saveToken(plaintext);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
@@ -58,9 +58,61 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
|
||||
await runAppReadyRuntime(deps);
|
||||
assert.ok(calls.includes('startSubtitleWebsocket:9001'));
|
||||
assert.ok(calls.includes('initializeOverlayRuntime'));
|
||||
assert.ok(calls.includes('createImmersionTracker'));
|
||||
assert.ok(calls.includes('startBackgroundWarmups'));
|
||||
assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.'));
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
'log:Runtime ready: immersion tracker startup deferred until first media activity.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns true', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
shouldSkipHeavyStartup: () => true,
|
||||
reloadConfig: () => calls.push('reloadConfig'),
|
||||
getResolvedConfig: () => {
|
||||
calls.push('getResolvedConfig');
|
||||
return {
|
||||
websocket: { enabled: 'auto' },
|
||||
secondarySub: {},
|
||||
};
|
||||
},
|
||||
getConfigWarnings: () => {
|
||||
calls.push('getConfigWarnings');
|
||||
return [];
|
||||
},
|
||||
setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`),
|
||||
initRuntimeOptionsManager: () => calls.push('initRuntimeOptionsManager'),
|
||||
startBackgroundWarmups: () => calls.push('startBackgroundWarmups'),
|
||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
||||
createMpvClient: () => calls.push('createMpvClient'),
|
||||
logConfigWarning: () => calls.push('logConfigWarning'),
|
||||
startJellyfinRemoteSession: async () => {
|
||||
calls.push('startJellyfinRemoteSession');
|
||||
},
|
||||
createImmersionTracker: () => calls.push('createImmersionTracker'),
|
||||
handleInitialArgs: () => calls.push('handleInitialArgs'),
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(calls.includes('reloadConfig'), false);
|
||||
assert.equal(calls.includes('getResolvedConfig'), false);
|
||||
assert.equal(calls.includes('getConfigWarnings'), false);
|
||||
assert.equal(calls.includes('setLogLevel:warn:config'), false);
|
||||
assert.equal(calls.includes('startBackgroundWarmups'), false);
|
||||
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
||||
assert.equal(calls.includes('resolveKeybindings'), false);
|
||||
assert.equal(calls.includes('createMpvClient'), false);
|
||||
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
||||
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
||||
assert.equal(calls.includes('logConfigWarning'), false);
|
||||
assert.equal(calls.includes('handleInitialArgs'), true);
|
||||
assert.equal(calls.includes('loadYomitanExtension'), true);
|
||||
assert.equal(calls[0], 'loadYomitanExtension');
|
||||
assert.equal(calls[calls.length - 1], 'handleInitialArgs');
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
|
||||
@@ -86,23 +138,7 @@ test('runAppReadyRuntime logs when createImmersionTracker dependency is missing'
|
||||
createImmersionTracker: undefined,
|
||||
});
|
||||
await runAppReadyRuntime(deps);
|
||||
assert.ok(calls.includes('log:Runtime ready: createImmersionTracker dependency is missing.'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime logs and continues when createImmersionTracker throws', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
createImmersionTracker: () => {
|
||||
calls.push('createImmersionTracker');
|
||||
throw new Error('immersion init failed');
|
||||
},
|
||||
});
|
||||
await runAppReadyRuntime(deps);
|
||||
assert.ok(calls.includes('createImmersionTracker'));
|
||||
assert.ok(
|
||||
calls.includes('log:Runtime ready: createImmersionTracker failed: immersion init failed'),
|
||||
);
|
||||
assert.ok(calls.includes('initializeOverlayRuntime'));
|
||||
assert.ok(calls.includes('handleInitialArgs'));
|
||||
assert.ok(calls.includes('log:Runtime ready: immersion tracker dependency is missing.'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => {
|
||||
@@ -144,12 +180,30 @@ test('runAppReadyRuntime does not await background warmups', async () => {
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
assert.deepEqual(calls.slice(0, 2), ['handleInitialArgs', 'startBackgroundWarmups']);
|
||||
assert.ok(calls.includes('startBackgroundWarmups'));
|
||||
assert.ok(calls.includes('handleInitialArgs'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
||||
assert.equal(calls.includes('warmupDone'), false);
|
||||
assert.ok(releaseWarmup);
|
||||
releaseWarmup();
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
|
||||
const calls: string[] = [];
|
||||
const { deps } = makeDeps({
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||
createMpvClient: () => calls.push('createMpvClient'),
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
|
||||
const capturedErrors: string[][] = [];
|
||||
const { deps, calls } = makeDeps({
|
||||
|
||||
@@ -10,14 +10,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
toggleInvisibleOverlay: false,
|
||||
settings: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
hideVisibleOverlay: false,
|
||||
showInvisibleOverlay: false,
|
||||
hideInvisibleOverlay: false,
|
||||
copySubtitle: false,
|
||||
copySubtitleMultiple: false,
|
||||
mineSentence: false,
|
||||
@@ -94,18 +91,12 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
toggleVisibleOverlay: () => {
|
||||
calls.push('toggleVisibleOverlay');
|
||||
},
|
||||
toggleInvisibleOverlay: () => {
|
||||
calls.push('toggleInvisibleOverlay');
|
||||
},
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
calls.push(`setVisibleOverlayVisible:${visible}`);
|
||||
},
|
||||
setInvisibleOverlayVisible: (visible) => {
|
||||
calls.push(`setInvisibleOverlayVisible:${visible}`);
|
||||
},
|
||||
copyCurrentSubtitle: () => {
|
||||
calls.push('copyCurrentSubtitle');
|
||||
},
|
||||
@@ -229,6 +220,18 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand applies cli log level for second-instance commands', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
setLogLevel: (level) => {
|
||||
calls.push(`setLogLevel:${level}`);
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ start: true, logLevel: 'debug' }), 'second-instance', deps);
|
||||
|
||||
assert.ok(calls.includes('setLogLevel:debug'));
|
||||
});
|
||||
|
||||
test('handleCliCommand runs texthooker flow with browser open', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
const args = makeArgs({ texthooker: true });
|
||||
@@ -339,10 +342,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
args: Partial<CliArgs>;
|
||||
expected: string;
|
||||
}> = [
|
||||
{
|
||||
args: { toggleInvisibleOverlay: true },
|
||||
expected: 'toggleInvisibleOverlay',
|
||||
},
|
||||
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
||||
{
|
||||
args: { showVisibleOverlay: true },
|
||||
@@ -352,14 +351,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
args: { hideVisibleOverlay: true },
|
||||
expected: 'setVisibleOverlayVisible:false',
|
||||
},
|
||||
{
|
||||
args: { showInvisibleOverlay: true },
|
||||
expected: 'setInvisibleOverlayVisible:true',
|
||||
},
|
||||
{
|
||||
args: { hideInvisibleOverlay: true },
|
||||
expected: 'setInvisibleOverlayVisible:false',
|
||||
},
|
||||
{ args: { copySubtitle: true }, expected: 'copyCurrentSubtitle' },
|
||||
{
|
||||
args: { copySubtitleMultiple: true },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
|
||||
|
||||
export interface CliCommandServiceDeps {
|
||||
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
|
||||
getMpvSocketPath: () => string;
|
||||
setMpvSocketPath: (socketPath: string) => void;
|
||||
setMpvClientSocketPath: (socketPath: string) => void;
|
||||
@@ -16,10 +17,8 @@ export interface CliCommandServiceDeps {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
@@ -93,9 +92,7 @@ interface OverlayCliRuntime {
|
||||
isInitialized: () => boolean;
|
||||
initialize: () => void;
|
||||
toggleVisible: () => void;
|
||||
toggleInvisible: () => void;
|
||||
setVisible: (visible: boolean) => void;
|
||||
setInvisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface MiningCliRuntime {
|
||||
@@ -131,6 +128,7 @@ interface AppCliRuntime {
|
||||
}
|
||||
|
||||
export interface CliCommandDepsRuntimeOptions {
|
||||
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
|
||||
mpv: MpvCliRuntime;
|
||||
texthooker: TexthookerCliRuntime;
|
||||
overlay: OverlayCliRuntime;
|
||||
@@ -153,6 +151,7 @@ export function createCliCommandDepsRuntime(
|
||||
options: CliCommandDepsRuntimeOptions,
|
||||
): CliCommandServiceDeps {
|
||||
return {
|
||||
setLogLevel: options.setLogLevel,
|
||||
getMpvSocketPath: options.mpv.getSocketPath,
|
||||
setMpvSocketPath: options.mpv.setSocketPath,
|
||||
setMpvClientSocketPath: (socketPath) => {
|
||||
@@ -180,14 +179,12 @@ export function createCliCommandDepsRuntime(
|
||||
isOverlayRuntimeInitialized: options.overlay.isInitialized,
|
||||
initializeOverlayRuntime: options.overlay.initialize,
|
||||
toggleVisibleOverlay: options.overlay.toggleVisible,
|
||||
toggleInvisibleOverlay: options.overlay.toggleInvisible,
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
options.ui.openYomitanSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
setInvisibleOverlayVisible: options.overlay.setInvisible,
|
||||
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
||||
mineSentenceCard: options.mining.mineSentenceCard,
|
||||
@@ -238,18 +235,19 @@ export function handleCliCommand(
|
||||
source: CliCommandSource = 'initial',
|
||||
deps: CliCommandServiceDeps,
|
||||
): void {
|
||||
if (args.logLevel) {
|
||||
deps.setLogLevel?.(args.logLevel);
|
||||
}
|
||||
|
||||
const hasNonStartAction =
|
||||
args.stop ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
args.hideVisibleOverlay ||
|
||||
args.showInvisibleOverlay ||
|
||||
args.hideInvisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
@@ -285,11 +283,7 @@ export function handleCliCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldStart =
|
||||
args.start ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay;
|
||||
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
|
||||
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
||||
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
||||
|
||||
@@ -325,18 +319,12 @@ export function handleCliCommand(
|
||||
|
||||
if (args.toggle || args.toggleVisibleOverlay) {
|
||||
deps.toggleVisibleOverlay();
|
||||
} else if (args.toggleInvisibleOverlay) {
|
||||
deps.toggleInvisibleOverlay();
|
||||
} else if (args.settings) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
} else if (args.hide || args.hideVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(false);
|
||||
} else if (args.showInvisibleOverlay) {
|
||||
deps.setInvisibleOverlayVisible(true);
|
||||
} else if (args.hideInvisibleOverlay) {
|
||||
deps.setInvisibleOverlayVisible(false);
|
||||
} else if (args.copySubtitle) {
|
||||
deps.copyCurrentSubtitle();
|
||||
} else if (args.copySubtitleMultiple) {
|
||||
|
||||
@@ -19,11 +19,9 @@ test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore
|
||||
},
|
||||
}),
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: (next) => {
|
||||
visible = next;
|
||||
},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => null,
|
||||
setResolver: () => {},
|
||||
getRestoreVisibleOverlayOnModalClose: () => restore,
|
||||
@@ -44,9 +42,7 @@ test('createFieldGroupingOverlayRuntime callback cancels when send fails', async
|
||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
resolver = next;
|
||||
@@ -87,12 +83,10 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay
|
||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: (nextVisible) => {
|
||||
visible = nextVisible;
|
||||
visibilityTransitions.push(nextVisible);
|
||||
},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null,
|
||||
setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
resolver = nextResolver;
|
||||
|
||||
@@ -11,9 +11,7 @@ interface WindowLike {
|
||||
export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<T>;
|
||||
@@ -65,9 +63,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
) => Promise<KikuFieldGroupingChoice>) => {
|
||||
return createFieldGroupingCallbackRuntime({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
|
||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
sendToVisibleOverlay,
|
||||
|
||||
@@ -2,9 +2,7 @@ import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../typ
|
||||
|
||||
export function createFieldGroupingCallback(options: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
|
||||
@@ -22,7 +20,6 @@ export function createFieldGroupingCallback(options: {
|
||||
}
|
||||
|
||||
const previousVisibleOverlay = options.getVisibleOverlayVisible();
|
||||
const previousInvisibleOverlay = options.getInvisibleOverlayVisible();
|
||||
let settled = false;
|
||||
|
||||
const finish = (choice: KikuFieldGroupingChoice): void => {
|
||||
@@ -36,9 +33,6 @@ export function createFieldGroupingCallback(options: {
|
||||
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
|
||||
options.setVisibleOverlayVisible(false);
|
||||
}
|
||||
if (options.getInvisibleOverlayVisible() !== previousInvisibleOverlay) {
|
||||
options.setInvisibleOverlayVisible(previousInvisibleOverlay);
|
||||
}
|
||||
};
|
||||
|
||||
options.setResolver(finish);
|
||||
|
||||
@@ -71,7 +71,8 @@ test('createFrequencyDictionaryLookup aggregates duplicate-term logs into a sing
|
||||
|
||||
assert.equal(lookup('猫'), 100);
|
||||
assert.equal(
|
||||
logs.filter((entry) => entry.includes('Frequency dictionary ignored 2 duplicate term entries')).length,
|
||||
logs.filter((entry) => entry.includes('Frequency dictionary ignored 2 duplicate term entries'))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
@@ -79,3 +80,52 @@ test('createFrequencyDictionaryLookup aggregates duplicate-term logs into a sing
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('createFrequencyDictionaryLookup prefers frequency.displayValue over value when both exist', async () => {
|
||||
const logs: string[] = [];
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
|
||||
const bankPath = path.join(tempDir, 'term_meta_bank_1.json');
|
||||
fs.writeFileSync(
|
||||
bankPath,
|
||||
JSON.stringify([
|
||||
['猫', 1, { frequency: { value: 1234, displayValue: 1200 } }],
|
||||
['鍛える', 2, { frequency: { value: 46961, displayValue: 2847 } }],
|
||||
['犬', 2, { frequency: { displayValue: 88 } }],
|
||||
]),
|
||||
);
|
||||
|
||||
const lookup = await createFrequencyDictionaryLookup({
|
||||
searchPaths: [tempDir],
|
||||
log: (message) => {
|
||||
logs.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(lookup('猫'), 1200);
|
||||
assert.equal(lookup('鍛える'), 2847);
|
||||
assert.equal(lookup('犬'), 88);
|
||||
assert.equal(
|
||||
logs.some((entry) => entry.includes('Frequency dictionary loaded from')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('createFrequencyDictionaryLookup parses composite displayValue by primary rank', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
|
||||
const bankPath = path.join(tempDir, 'term_meta_bank_1.json');
|
||||
fs.writeFileSync(
|
||||
bankPath,
|
||||
JSON.stringify([
|
||||
['鍛える', 1, { frequency: { displayValue: '3272,52377' } }],
|
||||
['高み', 2, { frequency: { displayValue: '9933,108961' } }],
|
||||
]),
|
||||
);
|
||||
|
||||
const lookup = await createFrequencyDictionaryLookup({
|
||||
searchPaths: [tempDir],
|
||||
log: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(lookup('鍛える'), 3272);
|
||||
assert.equal(lookup('高み'), 9933);
|
||||
});
|
||||
|
||||
@@ -18,23 +18,57 @@ function normalizeFrequencyTerm(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parsePositiveFrequencyString(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericPrefix = trimmed.match(/^\d[\d,]*/)?.[0];
|
||||
if (!numericPrefix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunks = numericPrefix.split(',');
|
||||
const normalizedNumber =
|
||||
chunks.length <= 1
|
||||
? (chunks[0] ?? '')
|
||||
: chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk))
|
||||
? chunks.join('')
|
||||
: (chunks[0] ?? '');
|
||||
const parsed = Number.parseInt(normalizedNumber, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveFrequencyNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return parsePositiveFrequencyString(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFrequencyDisplayValue(meta: unknown): number | null {
|
||||
if (!meta || typeof meta !== 'object') return null;
|
||||
const frequency = (meta as { frequency?: unknown }).frequency;
|
||||
if (!frequency || typeof frequency !== 'object') return null;
|
||||
const displayValue = (frequency as { displayValue?: unknown }).displayValue;
|
||||
if (typeof displayValue === 'number') {
|
||||
if (!Number.isFinite(displayValue) || displayValue <= 0) return null;
|
||||
return Math.floor(displayValue);
|
||||
}
|
||||
if (typeof displayValue === 'string') {
|
||||
const normalized = displayValue.trim().replace(/,/g, '');
|
||||
const parsed = Number.parseInt(normalized, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return parsed;
|
||||
const parsedDisplayValue = parsePositiveFrequencyNumber(displayValue);
|
||||
if (parsedDisplayValue !== null) {
|
||||
return parsedDisplayValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
const rawValue = (frequency as { value?: unknown }).value;
|
||||
return parsePositiveFrequencyNumber(rawValue);
|
||||
}
|
||||
|
||||
function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry | null {
|
||||
|
||||
@@ -74,8 +74,8 @@ test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () =>
|
||||
const result = enqueueWrite(queue, incoming, 2);
|
||||
assert.equal(result.dropped, 1);
|
||||
assert.equal(queue.length, 2);
|
||||
assert.equal(queue[0]!.eventType, 2);
|
||||
assert.equal(queue[1]!.eventType, 3);
|
||||
assert.equal((queue[0] as Extract<QueuedWrite, { kind: 'event' }>).eventType, 2);
|
||||
assert.equal((queue[1] as Extract<QueuedWrite, { kind: 'event' }>).eventType, 3);
|
||||
});
|
||||
|
||||
test('seam: toMonthKey uses UTC calendar month', () => {
|
||||
@@ -286,8 +286,8 @@ testIfSqlite('monthly rollups are grouped by calendar month', async () => {
|
||||
canonical_title,
|
||||
source_type,
|
||||
duration_ms,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1,
|
||||
'local:/tmp/video.mkv',
|
||||
@@ -306,8 +306,8 @@ testIfSqlite('monthly rollups are grouped by calendar month', async () => {
|
||||
video_id,
|
||||
started_at_ms,
|
||||
status,
|
||||
created_at_ms,
|
||||
updated_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE,
|
||||
ended_at_ms
|
||||
) VALUES (
|
||||
1,
|
||||
@@ -363,8 +363,8 @@ testIfSqlite('monthly rollups are grouped by calendar month', async () => {
|
||||
video_id,
|
||||
started_at_ms,
|
||||
status,
|
||||
created_at_ms,
|
||||
updated_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE,
|
||||
ended_at_ms
|
||||
) VALUES (
|
||||
2,
|
||||
@@ -479,8 +479,8 @@ testIfSqlite('flushSingle reuses cached prepared statements', async () => {
|
||||
canonical_title,
|
||||
source_type,
|
||||
duration_ms,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1,
|
||||
'local:/tmp/prepared.mkv',
|
||||
@@ -499,8 +499,8 @@ testIfSqlite('flushSingle reuses cached prepared statements', async () => {
|
||||
video_id,
|
||||
started_at_ms,
|
||||
status,
|
||||
created_at_ms,
|
||||
updated_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE,
|
||||
ended_at_ms
|
||||
) VALUES (
|
||||
1,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
buildVideoKey,
|
||||
calculateTextMetrics,
|
||||
extractLineVocabulary,
|
||||
deriveCanonicalTitle,
|
||||
isRemoteSource,
|
||||
normalizeMediaPath,
|
||||
@@ -268,18 +269,41 @@ export class ImmersionTrackerService {
|
||||
if (!this.sessionState || !text.trim()) return;
|
||||
const cleaned = normalizeText(text);
|
||||
if (!cleaned) return;
|
||||
const nowMs = Date.now();
|
||||
const nowSec = nowMs / 1000;
|
||||
|
||||
const metrics = calculateTextMetrics(cleaned);
|
||||
const extractedVocabulary = extractLineVocabulary(cleaned);
|
||||
this.sessionState.currentLineIndex += 1;
|
||||
this.sessionState.linesSeen += 1;
|
||||
this.sessionState.wordsSeen += metrics.words;
|
||||
this.sessionState.tokensSeen += metrics.tokens;
|
||||
this.sessionState.pendingTelemetry = true;
|
||||
|
||||
for (const { headword, word, reading } of extractedVocabulary.words) {
|
||||
this.recordWrite({
|
||||
kind: 'word',
|
||||
headword,
|
||||
word,
|
||||
reading,
|
||||
firstSeen: nowSec,
|
||||
lastSeen: nowSec,
|
||||
});
|
||||
}
|
||||
|
||||
for (const kanji of extractedVocabulary.kanji) {
|
||||
this.recordWrite({
|
||||
kind: 'kanji',
|
||||
kanji,
|
||||
firstSeen: nowSec,
|
||||
lastSeen: nowSec,
|
||||
});
|
||||
}
|
||||
|
||||
this.recordWrite({
|
||||
kind: 'event',
|
||||
sessionId: this.sessionState.sessionId,
|
||||
sampleMs: Date.now(),
|
||||
sampleMs: nowMs,
|
||||
lineIndex: this.sessionState.currentLineIndex,
|
||||
segmentStartMs: secToMs(startSec),
|
||||
segmentEndMs: secToMs(endSec),
|
||||
@@ -562,13 +586,15 @@ export class ImmersionTrackerService {
|
||||
this.flushTelemetry(true);
|
||||
this.flushNow();
|
||||
const nowMs = Date.now();
|
||||
pruneRetention(this.db, nowMs, {
|
||||
const retentionResult = pruneRetention(this.db, nowMs, {
|
||||
eventsRetentionMs: this.eventsRetentionMs,
|
||||
telemetryRetentionMs: this.telemetryRetentionMs,
|
||||
dailyRollupRetentionMs: this.dailyRollupRetentionMs,
|
||||
monthlyRollupRetentionMs: this.monthlyRollupRetentionMs,
|
||||
});
|
||||
this.runRollupMaintenance();
|
||||
const shouldRebuildRollups =
|
||||
retentionResult.deletedTelemetryRows > 0 || retentionResult.deletedEndedSessions > 0;
|
||||
this.runRollupMaintenance(shouldRebuildRollups);
|
||||
|
||||
if (nowMs - this.lastVacuumMs >= this.vacuumIntervalMs && !this.writeLock.locked) {
|
||||
this.db.exec('VACUUM');
|
||||
@@ -582,8 +608,8 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
}
|
||||
|
||||
private runRollupMaintenance(): void {
|
||||
runRollupMaintenance(this.db);
|
||||
private runRollupMaintenance(forceRebuild = false): void {
|
||||
runRollupMaintenance(this.db, forceRebuild);
|
||||
}
|
||||
|
||||
private startSession(videoId: number, startedAtMs?: number): void {
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
|
||||
const DAILY_MS = 86_400_000;
|
||||
const ZERO_ID = 0;
|
||||
|
||||
interface RollupStateRow {
|
||||
state_value: number;
|
||||
}
|
||||
|
||||
interface RollupGroupRow {
|
||||
rollup_day: number;
|
||||
rollup_month: number;
|
||||
video_id: number;
|
||||
}
|
||||
|
||||
interface RollupTelemetryResult {
|
||||
maxSampleMs: number | null;
|
||||
}
|
||||
|
||||
interface RetentionResult {
|
||||
deletedSessionEvents: number;
|
||||
deletedTelemetryRows: number;
|
||||
deletedDailyRows: number;
|
||||
deletedMonthlyRows: number;
|
||||
deletedEndedSessions: number;
|
||||
}
|
||||
|
||||
export function toMonthKey(timestampMs: number): number {
|
||||
const monthDate = new Date(timestampMs);
|
||||
return monthDate.getUTCFullYear() * 100 + monthDate.getUTCMonth() + 1;
|
||||
@@ -14,29 +40,68 @@ export function pruneRetention(
|
||||
dailyRollupRetentionMs: number;
|
||||
monthlyRollupRetentionMs: number;
|
||||
},
|
||||
): void {
|
||||
): RetentionResult {
|
||||
const eventCutoff = nowMs - policy.eventsRetentionMs;
|
||||
const telemetryCutoff = nowMs - policy.telemetryRetentionMs;
|
||||
const dailyCutoff = nowMs - policy.dailyRollupRetentionMs;
|
||||
const monthlyCutoff = nowMs - policy.monthlyRollupRetentionMs;
|
||||
const dayCutoff = Math.floor(dailyCutoff / 86_400_000);
|
||||
const monthCutoff = toMonthKey(monthlyCutoff);
|
||||
const dayCutoff = nowMs - policy.dailyRollupRetentionMs;
|
||||
const monthCutoff = nowMs - policy.monthlyRollupRetentionMs;
|
||||
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff);
|
||||
db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff);
|
||||
db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff);
|
||||
db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff);
|
||||
db.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`).run(
|
||||
telemetryCutoff,
|
||||
);
|
||||
const deletedSessionEvents = (db
|
||||
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
|
||||
.run(eventCutoff) as { changes: number }).changes;
|
||||
const deletedTelemetryRows = (db
|
||||
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
|
||||
.run(telemetryCutoff) as { changes: number }).changes;
|
||||
const deletedDailyRows = (db
|
||||
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
|
||||
.run(Math.floor(dayCutoff / DAILY_MS)) as { changes: number }).changes;
|
||||
const deletedMonthlyRows = (db
|
||||
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`)
|
||||
.run(toMonthKey(monthCutoff)) as { changes: number }).changes;
|
||||
const deletedEndedSessions = (db
|
||||
.prepare(
|
||||
`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`,
|
||||
)
|
||||
.run(telemetryCutoff) as { changes: number }).changes;
|
||||
|
||||
return {
|
||||
deletedSessionEvents,
|
||||
deletedTelemetryRows,
|
||||
deletedDailyRows,
|
||||
deletedMonthlyRows,
|
||||
deletedEndedSessions,
|
||||
};
|
||||
}
|
||||
|
||||
export function runRollupMaintenance(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
INSERT OR REPLACE INTO imm_daily_rollups (
|
||||
function getLastRollupSampleMs(db: DatabaseSync): number {
|
||||
const row = db
|
||||
.prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`)
|
||||
.get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null;
|
||||
return row ? Number(row.state_value) : ZERO_ID;
|
||||
}
|
||||
|
||||
function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number): void {
|
||||
db.prepare(
|
||||
`INSERT INTO imm_rollup_state (state_key, state_value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(state_key) DO UPDATE SET state_value = excluded.state_value`,
|
||||
).run(ROLLUP_STATE_KEY, sampleMs);
|
||||
}
|
||||
|
||||
function upsertDailyRollupsForGroups(
|
||||
db: DatabaseSync,
|
||||
groups: Array<{ rollupDay: number; videoId: number }>,
|
||||
rollupNowMs: number,
|
||||
): void {
|
||||
if (groups.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const upsertStmt = db.prepare(`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_words_seen, total_tokens_seen, total_cards, cards_per_hour,
|
||||
words_per_min, lookup_hit_rate
|
||||
words_per_min, lookup_hit_rate, CREATED_DATE, LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day,
|
||||
@@ -61,17 +126,46 @@ export function runRollupMaintenance(db: DatabaseSync): void {
|
||||
WHEN COALESCE(SUM(t.lookup_count), 0) > 0
|
||||
THEN CAST(COALESCE(SUM(t.lookup_hits), 0) AS REAL) / CAST(SUM(t.lookup_count) AS REAL)
|
||||
ELSE NULL
|
||||
END AS lookup_hit_rate
|
||||
END AS lookup_hit_rate,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM imm_sessions s
|
||||
JOIN imm_session_telemetry t
|
||||
ON t.session_id = s.session_id
|
||||
WHERE CAST(s.started_at_ms / 86400000 AS INTEGER) = ? AND s.video_id = ?
|
||||
GROUP BY rollup_day, s.video_id
|
||||
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
|
||||
total_sessions = excluded.total_sessions,
|
||||
total_active_min = excluded.total_active_min,
|
||||
total_lines_seen = excluded.total_lines_seen,
|
||||
total_words_seen = excluded.total_words_seen,
|
||||
total_tokens_seen = excluded.total_tokens_seen,
|
||||
total_cards = excluded.total_cards,
|
||||
cards_per_hour = excluded.cards_per_hour,
|
||||
words_per_min = excluded.words_per_min,
|
||||
lookup_hit_rate = excluded.lookup_hit_rate,
|
||||
CREATED_DATE = COALESCE(imm_daily_rollups.CREATED_DATE, excluded.CREATED_DATE),
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
INSERT OR REPLACE INTO imm_monthly_rollups (
|
||||
for (const { rollupDay, videoId } of groups) {
|
||||
upsertStmt.run(rollupNowMs, rollupNowMs, rollupDay, videoId);
|
||||
}
|
||||
}
|
||||
|
||||
function upsertMonthlyRollupsForGroups(
|
||||
db: DatabaseSync,
|
||||
groups: Array<{ rollupMonth: number; videoId: number }>,
|
||||
rollupNowMs: number,
|
||||
): void {
|
||||
if (groups.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const upsertStmt = db.prepare(`
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_words_seen, total_tokens_seen, total_cards
|
||||
total_words_seen, total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month,
|
||||
@@ -81,10 +175,112 @@ export function runRollupMaintenance(db: DatabaseSync): void {
|
||||
COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen,
|
||||
COALESCE(SUM(t.words_seen), 0) AS total_words_seen,
|
||||
COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen,
|
||||
COALESCE(SUM(t.cards_mined), 0) AS total_cards
|
||||
COALESCE(SUM(t.cards_mined), 0) AS total_cards,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM imm_sessions s
|
||||
JOIN imm_session_telemetry t
|
||||
ON t.session_id = s.session_id
|
||||
WHERE CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) = ? AND s.video_id = ?
|
||||
GROUP BY rollup_month, s.video_id
|
||||
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
|
||||
total_sessions = excluded.total_sessions,
|
||||
total_active_min = excluded.total_active_min,
|
||||
total_lines_seen = excluded.total_lines_seen,
|
||||
total_words_seen = excluded.total_words_seen,
|
||||
total_tokens_seen = excluded.total_tokens_seen,
|
||||
total_cards = excluded.total_cards,
|
||||
CREATED_DATE = COALESCE(imm_monthly_rollups.CREATED_DATE, excluded.CREATED_DATE),
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
||||
`);
|
||||
|
||||
for (const { rollupMonth, videoId } of groups) {
|
||||
upsertStmt.run(rollupNowMs, rollupNowMs, rollupMonth, videoId);
|
||||
}
|
||||
}
|
||||
|
||||
function getAffectedRollupGroups(
|
||||
db: DatabaseSync,
|
||||
lastRollupSampleMs: number,
|
||||
): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> {
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT
|
||||
CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day,
|
||||
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month,
|
||||
s.video_id AS video_id
|
||||
FROM imm_session_telemetry t
|
||||
JOIN imm_sessions s
|
||||
ON s.session_id = t.session_id
|
||||
WHERE t.sample_ms > ?
|
||||
`,
|
||||
)
|
||||
.all(lastRollupSampleMs) as unknown as RollupGroupRow[]
|
||||
).map((row) => ({
|
||||
rollupDay: row.rollup_day,
|
||||
rollupMonth: row.rollup_month,
|
||||
videoId: row.video_id,
|
||||
}));
|
||||
}
|
||||
|
||||
function dedupeGroups<T extends { rollupDay?: number; rollupMonth?: number; videoId: number }>(
|
||||
groups: Array<T>,
|
||||
): Array<T> {
|
||||
const seen = new Set<string>();
|
||||
const result: Array<T> = [];
|
||||
for (const group of groups) {
|
||||
const key = `${group.rollupDay ?? group.rollupMonth}-${group.videoId}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(group);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): void {
|
||||
const rollupNowMs = Date.now();
|
||||
const lastRollupSampleMs = forceRebuild ? ZERO_ID : getLastRollupSampleMs(db);
|
||||
|
||||
const maxSampleRow = db
|
||||
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
|
||||
.get() as unknown as RollupTelemetryResult | null;
|
||||
if (!maxSampleRow?.maxSampleMs) {
|
||||
if (forceRebuild) {
|
||||
setLastRollupSampleMs(db, ZERO_ID);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const affectedGroups = getAffectedRollupGroups(db, lastRollupSampleMs);
|
||||
if (!forceRebuild && affectedGroups.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dailyGroups = dedupeGroups(
|
||||
affectedGroups.map((group) => ({
|
||||
rollupDay: group.rollupDay,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
const monthlyGroups = dedupeGroups(
|
||||
affectedGroups.map((group) => ({
|
||||
rollupMonth: group.rollupMonth,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
|
||||
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
|
||||
setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs));
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
22
src/core/services/immersion-tracker/reducer.test.ts
Normal file
22
src/core/services/immersion-tracker/reducer.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractLineVocabulary, isKanji } from './reducer';
|
||||
|
||||
test('isKanji follows canonical CJK ranges', () => {
|
||||
assert.ok(isKanji('日'));
|
||||
assert.ok(isKanji('𠀀'));
|
||||
assert.ok(!isKanji('あ'));
|
||||
assert.ok(!isKanji('a'));
|
||||
});
|
||||
|
||||
test('extractLineVocabulary returns words and unique kanji', () => {
|
||||
const result = extractLineVocabulary('hello 你好 猫');
|
||||
|
||||
assert.equal(result.words.length, 3);
|
||||
assert.deepEqual(
|
||||
new Set(result.words.map((entry) => `${entry.headword}/${entry.word}`)),
|
||||
new Set(['hello/hello', '你好/你好', '猫/猫']),
|
||||
);
|
||||
assert.equal(result.words.every((entry) => entry.reading === ''), true);
|
||||
assert.deepEqual(new Set(result.kanji), new Set(['你', '好', '猫']));
|
||||
});
|
||||
@@ -76,6 +76,53 @@ export function normalizeText(value: string | null | undefined): string {
|
||||
return value.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export interface ExtractedLineVocabulary {
|
||||
words: Array<{ headword: string; word: string; reading: string }>;
|
||||
kanji: string[];
|
||||
}
|
||||
|
||||
export function isKanji(char: string): boolean {
|
||||
if (!char) return false;
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) return false;
|
||||
return (
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0x3400 && code <= 0x4dbf) ||
|
||||
(code >= 0x20000 && code <= 0x2a6df)
|
||||
);
|
||||
}
|
||||
|
||||
export function extractLineVocabulary(value: string): ExtractedLineVocabulary {
|
||||
const cleaned = normalizeText(value);
|
||||
if (!cleaned) return { words: [], kanji: [] };
|
||||
|
||||
const wordSet = new Set<string>();
|
||||
const tokenPattern = /[A-Za-z0-9']+|[\u3040-\u30ff]+|[\u3400-\u4dbf\u4e00-\u9fff\u20000-\u2a6df]+/g;
|
||||
const rawWords = cleaned.match(tokenPattern) ?? [];
|
||||
for (const rawWord of rawWords) {
|
||||
const normalizedWord = normalizeText(rawWord.toLowerCase());
|
||||
if (!normalizedWord) continue;
|
||||
wordSet.add(normalizedWord);
|
||||
}
|
||||
|
||||
const kanji = new Set<string>();
|
||||
for (const char of cleaned) {
|
||||
if (isKanji(char)) {
|
||||
kanji.add(char);
|
||||
}
|
||||
}
|
||||
|
||||
const words = Array.from(wordSet).map((word) => ({
|
||||
headword: word,
|
||||
word,
|
||||
reading: '',
|
||||
}));
|
||||
return {
|
||||
words,
|
||||
kanji: Array.from(kanji),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVideoKey(mediaPath: string, sourceType: number): string {
|
||||
if (sourceType === SOURCE_TYPE_REMOTE) {
|
||||
return `remote:${mediaPath}`;
|
||||
|
||||
@@ -10,15 +10,24 @@ export function startSessionRecord(
|
||||
startedAtMs = Date.now(),
|
||||
): { sessionId: number; state: SessionState } {
|
||||
const sessionUuid = crypto.randomUUID();
|
||||
const nowMs = Date.now();
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_sessions (
|
||||
session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, startedAtMs);
|
||||
session_uuid, video_id, started_at_ms, status,
|
||||
CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
sessionUuid,
|
||||
videoId,
|
||||
startedAtMs,
|
||||
SESSION_STATUS_ACTIVE,
|
||||
startedAtMs,
|
||||
nowMs,
|
||||
);
|
||||
const sessionId = Number(result.lastInsertRowid);
|
||||
return {
|
||||
sessionId,
|
||||
@@ -32,6 +41,13 @@ export function finalizeSessionRecord(
|
||||
endedAtMs = Date.now(),
|
||||
): void {
|
||||
db.prepare(
|
||||
'UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?',
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
ended_at_ms = ?,
|
||||
status = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(endedAtMs, SESSION_STATUS_ENDED, Date.now(), sessionState.sessionId);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,19 @@ testIfSqlite('ensureSchema creates immersion core tables', () => {
|
||||
assert.ok(tableNames.has('imm_session_events'));
|
||||
assert.ok(tableNames.has('imm_daily_rollups'));
|
||||
assert.ok(tableNames.has('imm_monthly_rollups'));
|
||||
assert.ok(tableNames.has('imm_words'));
|
||||
assert.ok(tableNames.has('imm_kanji'));
|
||||
assert.ok(tableNames.has('imm_rollup_state'));
|
||||
|
||||
const rollupStateRow = db
|
||||
.prepare(
|
||||
'SELECT state_value FROM imm_rollup_state WHERE state_key = ?',
|
||||
)
|
||||
.get('last_rollup_sample_ms') as {
|
||||
state_value: number;
|
||||
} | null;
|
||||
assert.ok(rollupStateRow);
|
||||
assert.equal(rollupStateRow?.state_value, 0);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
@@ -160,3 +173,47 @@ testIfSqlite('executeQueuedWrite inserts event and telemetry rows', () => {
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
testIfSqlite('executeQueuedWrite inserts and upserts word and kanji rows', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new DatabaseSync!(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
|
||||
stmts.wordUpsertStmt.run('猫', '猫', '', 10.0, 10.0);
|
||||
stmts.wordUpsertStmt.run('猫', '猫', '', 5.0, 15.0);
|
||||
stmts.kanjiUpsertStmt.run('日', 9.0, 9.0);
|
||||
stmts.kanjiUpsertStmt.run('日', 8.0, 11.0);
|
||||
|
||||
const wordRow = db
|
||||
.prepare('SELECT headword, frequency, first_seen, last_seen FROM imm_words WHERE headword = ?')
|
||||
.get('猫') as {
|
||||
headword: string;
|
||||
frequency: number;
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
} | null;
|
||||
const kanjiRow = db
|
||||
.prepare('SELECT kanji, frequency, first_seen, last_seen FROM imm_kanji WHERE kanji = ?')
|
||||
.get('日') as {
|
||||
kanji: string;
|
||||
frequency: number;
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
} | null;
|
||||
|
||||
assert.ok(wordRow);
|
||||
assert.ok(kanjiRow);
|
||||
assert.equal(wordRow?.frequency, 2);
|
||||
assert.equal(kanjiRow?.frequency, 2);
|
||||
assert.equal(wordRow?.first_seen, 5);
|
||||
assert.equal(wordRow?.last_seen, 15);
|
||||
assert.equal(kanjiRow?.first_seen, 8);
|
||||
assert.equal(kanjiRow?.last_seen, 11);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,27 @@ import type { QueuedWrite, VideoMetadata } from './types';
|
||||
export interface TrackerPreparedStatements {
|
||||
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
eventInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
}
|
||||
|
||||
function hasColumn(db: DatabaseSync, tableName: string, columnName: string): boolean {
|
||||
return db
|
||||
.prepare(`PRAGMA table_info(${tableName})`)
|
||||
.all()
|
||||
.some((row) => (row as { name: string }).name === columnName);
|
||||
}
|
||||
|
||||
function addColumnIfMissing(db: DatabaseSync, tableName: string, columnName: string): void {
|
||||
if (!hasColumn(db, tableName, columnName)) {
|
||||
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} INTEGER`);
|
||||
}
|
||||
}
|
||||
|
||||
function dropColumnIfExists(db: DatabaseSync, tableName: string, columnName: string): void {
|
||||
if (hasColumn(db, tableName, columnName)) {
|
||||
db.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPragmas(db: DatabaseSync): void {
|
||||
@@ -21,6 +42,17 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
applied_at_ms INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_rollup_state(
|
||||
state_key TEXT PRIMARY KEY,
|
||||
state_value INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO imm_rollup_state(state_key, state_value)
|
||||
VALUES ('last_rollup_sample_ms', 0)
|
||||
ON CONFLICT(state_key) DO NOTHING
|
||||
`);
|
||||
|
||||
const currentVersion = db
|
||||
.prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1')
|
||||
@@ -44,7 +76,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
bitrate_kbps INTEGER, audio_codec_id INTEGER,
|
||||
hash_sha256 TEXT, screenshot_path TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
@@ -56,7 +89,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
status INTEGER NOT NULL,
|
||||
locale_id INTEGER, target_lang_id INTEGER,
|
||||
difficulty_tier INTEGER, subtitle_mode INTEGER,
|
||||
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
|
||||
);
|
||||
`);
|
||||
@@ -78,6 +112,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
seek_forward_count INTEGER NOT NULL DEFAULT 0,
|
||||
seek_backward_count INTEGER NOT NULL DEFAULT 0,
|
||||
media_buffer_events INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
@@ -93,6 +129,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
words_delta INTEGER NOT NULL DEFAULT 0,
|
||||
cards_delta INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
@@ -109,6 +147,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
cards_per_hour REAL,
|
||||
words_per_min REAL,
|
||||
lookup_hit_rate REAL,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
PRIMARY KEY (rollup_day, video_id)
|
||||
);
|
||||
`);
|
||||
@@ -122,9 +162,33 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
total_words_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
PRIMARY KEY (rollup_month, video_id)
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_words(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
headword TEXT,
|
||||
word TEXT,
|
||||
reading TEXT,
|
||||
first_seen REAL,
|
||||
last_seen REAL,
|
||||
frequency INTEGER,
|
||||
UNIQUE(headword, word, reading)
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_kanji(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kanji TEXT,
|
||||
first_seen REAL,
|
||||
last_seen REAL,
|
||||
frequency INTEGER,
|
||||
UNIQUE(kanji)
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_video_started
|
||||
@@ -154,6 +218,86 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_rollups_month_video
|
||||
ON imm_monthly_rollups(rollup_month, video_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_words_headword_word_reading
|
||||
ON imm_words(headword, word, reading)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_kanji_kanji
|
||||
ON imm_kanji(kanji)
|
||||
`);
|
||||
|
||||
if (currentVersion?.schema_version === 1) {
|
||||
addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE');
|
||||
|
||||
const nowMs = Date.now();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET
|
||||
CREATED_DATE = COALESCE(CREATED_DATE, created_at_ms),
|
||||
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, created_at_ms)
|
||||
`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
CREATED_DATE = COALESCE(CREATED_DATE, started_at_ms),
|
||||
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, created_at_ms)
|
||||
`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_session_telemetry
|
||||
SET
|
||||
CREATED_DATE = COALESCE(CREATED_DATE, sample_ms),
|
||||
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, sample_ms)
|
||||
`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_session_events
|
||||
SET
|
||||
CREATED_DATE = COALESCE(CREATED_DATE, ts_ms),
|
||||
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, ts_ms)
|
||||
`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_daily_rollups
|
||||
SET
|
||||
CREATED_DATE = COALESCE(CREATED_DATE, ?),
|
||||
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, ?)
|
||||
`,
|
||||
).run(nowMs, nowMs);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_monthly_rollups
|
||||
SET
|
||||
CREATED_DATE = COALESCE(CREATED_DATE, ?),
|
||||
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, ?)
|
||||
`,
|
||||
).run(nowMs, nowMs);
|
||||
}
|
||||
|
||||
if (currentVersion?.schema_version === 1 || currentVersion?.schema_version === 2) {
|
||||
dropColumnIfExists(db, 'imm_videos', 'created_at_ms');
|
||||
dropColumnIfExists(db, 'imm_videos', 'updated_at_ms');
|
||||
dropColumnIfExists(db, 'imm_sessions', 'created_at_ms');
|
||||
dropColumnIfExists(db, 'imm_sessions', 'updated_at_ms');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO imm_schema_version(schema_version, applied_at_ms)
|
||||
@@ -169,19 +313,41 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
session_id, sample_ms, total_watched_ms, active_watched_ms,
|
||||
lines_seen, words_seen, tokens_seen, cards_mined, lookup_count,
|
||||
lookup_hits, pause_count, pause_ms, seek_forward_count,
|
||||
seek_backward_count, media_buffer_events
|
||||
seek_backward_count, media_buffer_events, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
eventInsertStmt: db.prepare(`
|
||||
INSERT INTO imm_session_events (
|
||||
session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
|
||||
words_delta, cards_delta, payload_json
|
||||
words_delta, cards_delta, payload_json, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
wordUpsertStmt: db.prepare(`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, first_seen, last_seen, frequency
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, 1
|
||||
)
|
||||
ON CONFLICT(headword, word, reading) DO UPDATE SET
|
||||
frequency = COALESCE(frequency, 0) + 1,
|
||||
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
|
||||
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen)
|
||||
`),
|
||||
kanjiUpsertStmt: db.prepare(`
|
||||
INSERT INTO imm_kanji (
|
||||
kanji, first_seen, last_seen, frequency
|
||||
) VALUES (
|
||||
?, ?, ?, 1
|
||||
)
|
||||
ON CONFLICT(kanji) DO UPDATE SET
|
||||
frequency = COALESCE(frequency, 0) + 1,
|
||||
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
|
||||
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen)
|
||||
`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,9 +369,25 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
write.seekForwardCount!,
|
||||
write.seekBackwardCount!,
|
||||
write.mediaBufferEvents!,
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (write.kind === 'word') {
|
||||
stmts.wordUpsertStmt.run(
|
||||
write.headword,
|
||||
write.word,
|
||||
write.reading,
|
||||
write.firstSeen,
|
||||
write.lastSeen,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (write.kind === 'kanji') {
|
||||
stmts.kanjiUpsertStmt.run(write.kanji, write.firstSeen, write.lastSeen);
|
||||
return;
|
||||
}
|
||||
|
||||
stmts.eventInsertStmt.run(
|
||||
write.sessionId,
|
||||
@@ -217,6 +399,8 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
write.wordsDelta ?? 0,
|
||||
write.cardsDelta ?? 0,
|
||||
write.payloadJson ?? null,
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,8 +419,18 @@ export function getOrCreateVideoRecord(
|
||||
.get(videoKey) as { video_id: number } | null;
|
||||
if (existing?.video_id) {
|
||||
db.prepare(
|
||||
'UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?',
|
||||
).run(details.canonicalTitle || 'unknown', Date.now(), existing.video_id);
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET
|
||||
canonical_title = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(
|
||||
details.canonicalTitle || 'unknown',
|
||||
Date.now(),
|
||||
existing.video_id,
|
||||
);
|
||||
return existing.video_id;
|
||||
}
|
||||
|
||||
@@ -246,7 +440,7 @@ export function getOrCreateVideoRecord(
|
||||
video_key, canonical_title, source_type, source_path, source_url,
|
||||
duration_ms, file_size_bytes, codec_id, container_id, width_px, height_px,
|
||||
fps_x100, bitrate_kbps, audio_codec_id, hash_sha256, screenshot_path,
|
||||
metadata_json, created_at_ms, updated_at_ms
|
||||
metadata_json, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const result = insert.run(
|
||||
@@ -294,7 +488,7 @@ export function updateVideoMetadataRecord(
|
||||
hash_sha256 = ?,
|
||||
screenshot_path = ?,
|
||||
metadata_json = ?,
|
||||
updated_at_ms = ?
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(
|
||||
@@ -320,9 +514,13 @@ export function updateVideoTitleRecord(
|
||||
videoId: number,
|
||||
canonicalTitle: string,
|
||||
): void {
|
||||
db.prepare('UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?').run(
|
||||
canonicalTitle,
|
||||
Date.now(),
|
||||
videoId,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET
|
||||
canonical_title = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(canonicalTitle, Date.now(), videoId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SCHEMA_VERSION = 1;
|
||||
export const SCHEMA_VERSION = 3;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
@@ -74,8 +74,8 @@ export interface SessionState extends TelemetryAccumulator {
|
||||
pendingTelemetry: boolean;
|
||||
}
|
||||
|
||||
export interface QueuedWrite {
|
||||
kind: 'telemetry' | 'event';
|
||||
interface QueuedTelemetryWrite {
|
||||
kind: 'telemetry';
|
||||
sessionId: number;
|
||||
sampleMs?: number;
|
||||
totalWatchedMs?: number;
|
||||
@@ -100,6 +100,37 @@ export interface QueuedWrite {
|
||||
payloadJson?: string | null;
|
||||
}
|
||||
|
||||
interface QueuedEventWrite {
|
||||
kind: 'event';
|
||||
sessionId: number;
|
||||
sampleMs?: number;
|
||||
eventType?: number;
|
||||
lineIndex?: number | null;
|
||||
segmentStartMs?: number | null;
|
||||
segmentEndMs?: number | null;
|
||||
wordsDelta?: number;
|
||||
cardsDelta?: number;
|
||||
payloadJson?: string | null;
|
||||
}
|
||||
|
||||
interface QueuedWordWrite {
|
||||
kind: 'word';
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
interface QueuedKanjiWrite {
|
||||
kind: 'kanji';
|
||||
kanji: string;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export type QueuedWrite = QueuedTelemetryWrite | QueuedEventWrite | QueuedWordWrite | QueuedKanjiWrite;
|
||||
|
||||
export interface VideoMetadata {
|
||||
sourceType: number;
|
||||
canonicalTitle: string;
|
||||
|
||||
@@ -23,13 +23,13 @@ export {
|
||||
export { createAppLifecycleDepsRuntime, startAppLifecycle } from './app-lifecycle';
|
||||
export { cycleSecondarySubMode } from './subtitle-position';
|
||||
export {
|
||||
getInitialInvisibleOverlayVisibility,
|
||||
isAutoUpdateEnabledRuntime,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
shouldBindVisibleOverlayToMpvSubVisibility,
|
||||
} from './startup';
|
||||
export { openYomitanSettingsWindow } from './yomitan-settings';
|
||||
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
||||
export { clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime';
|
||||
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
|
||||
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||
@@ -59,16 +59,11 @@ export {
|
||||
createOverlayWindow,
|
||||
enforceOverlayLayerOrder,
|
||||
ensureOverlayWindowLevel,
|
||||
syncOverlayWindowLayer,
|
||||
updateOverlayWindowBounds,
|
||||
} from './overlay-window';
|
||||
export { initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
export {
|
||||
setInvisibleOverlayVisible,
|
||||
setVisibleOverlayVisible,
|
||||
syncInvisibleOverlayMousePassthrough,
|
||||
updateInvisibleOverlayVisibility,
|
||||
updateVisibleOverlayVisibility,
|
||||
} from './overlay-visibility';
|
||||
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
||||
export {
|
||||
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||
MpvIpcClient,
|
||||
@@ -76,6 +71,7 @@ export {
|
||||
replayCurrentSubtitleRuntime,
|
||||
resolveCurrentAudioStreamIndex,
|
||||
sendMpvCommandRuntime,
|
||||
setMpvSecondarySubVisibilityRuntime,
|
||||
setMpvSubVisibilityRuntime,
|
||||
showMpvOsdRuntime,
|
||||
} from './mpv';
|
||||
|
||||
78
src/core/services/ipc-command.test.ts
Normal file
78
src/core/services/ipc-command.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { handleMpvCommandFromIpc } from './ipc-command';
|
||||
|
||||
function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFromIpc>[1]> = {}) {
|
||||
const calls: string[] = [];
|
||||
const sentCommands: (string | number)[][] = [];
|
||||
const osd: string[] = [];
|
||||
const options: Parameters<typeof handleMpvCommandFromIpc>[1] = {
|
||||
specialCommands: {
|
||||
SUBSYNC_TRIGGER: '__subsync-trigger',
|
||||
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
},
|
||||
triggerSubsyncFromConfig: () => {
|
||||
calls.push('subsync');
|
||||
},
|
||||
openRuntimeOptionsPalette: () => {
|
||||
calls.push('runtime-options');
|
||||
},
|
||||
runtimeOptionsCycle: () => ({ ok: true }),
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
mpvReplaySubtitle: () => {
|
||||
calls.push('replay');
|
||||
},
|
||||
mpvPlayNextSubtitle: () => {
|
||||
calls.push('next');
|
||||
},
|
||||
mpvSendCommand: (command) => {
|
||||
sentCommands.push(command);
|
||||
},
|
||||
isMpvConnected: () => true,
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
...overrides,
|
||||
};
|
||||
return { options, calls, sentCommands, osd };
|
||||
}
|
||||
|
||||
test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['cycle', 'pause'], options);
|
||||
assert.deepEqual(sentCommands, [['cycle', 'pause']]);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle position keybinding proxies', () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-pos', 1], options);
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-pos', 1]]);
|
||||
assert.deepEqual(osd, ['Subtitle position: ${sub-pos}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for primary subtitle track keybinding proxies', () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['cycle', 'sid'], options);
|
||||
assert.deepEqual(sentCommands, [['cycle', 'sid']]);
|
||||
assert.deepEqual(osd, ['Subtitle track: ${sid}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for secondary subtitle track keybinding proxies', () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['set_property', 'secondary-sid', 'auto'], options);
|
||||
assert.deepEqual(sentCommands, [['set_property', 'secondary-sid', 'auto']]);
|
||||
assert.deepEqual(osd, ['Secondary subtitle track: ${secondary-sid}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
isMpvConnected: () => false,
|
||||
});
|
||||
handleMpvCommandFromIpc(['add', 'sub-pos', 1], options);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
@@ -24,6 +24,31 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
hasRuntimeOptionsManager: () => boolean;
|
||||
}
|
||||
|
||||
const MPV_PROPERTY_COMMANDS = new Set([
|
||||
'add',
|
||||
'set',
|
||||
'set_property',
|
||||
'cycle',
|
||||
'cycle-values',
|
||||
'multiply',
|
||||
]);
|
||||
|
||||
function resolveProxyCommandOsd(command: (string | number)[]): string | null {
|
||||
const operation = typeof command[0] === 'string' ? command[0] : '';
|
||||
const property = typeof command[1] === 'string' ? command[1] : '';
|
||||
if (!MPV_PROPERTY_COMMANDS.has(operation)) return null;
|
||||
if (property === 'sub-pos') {
|
||||
return 'Subtitle position: ${sub-pos}';
|
||||
}
|
||||
if (property === 'sid') {
|
||||
return 'Subtitle track: ${sid}';
|
||||
}
|
||||
if (property === 'secondary-sid') {
|
||||
return 'Secondary subtitle track: ${secondary-sid}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function handleMpvCommandFromIpc(
|
||||
command: (string | number)[],
|
||||
options: HandleMpvCommandFromIpcOptions,
|
||||
@@ -58,6 +83,10 @@ export function handleMpvCommandFromIpc(
|
||||
options.mpvPlayNextSubtitle();
|
||||
} else {
|
||||
options.mpvSendCommand(command);
|
||||
const osd = resolveProxyCommandOsd(command);
|
||||
if (osd) {
|
||||
options.showMpvOsd(osd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,8 @@ function createFakeIpcRegistrar(): {
|
||||
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createIpcDepsRuntime({
|
||||
getInvisibleWindow: () => null,
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
onOverlayModalClosed: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
@@ -47,7 +45,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
@@ -64,7 +62,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
|
||||
clearAnilistToken: () => {
|
||||
calls.push('clearAnilistToken');
|
||||
@@ -93,6 +90,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
message: 'done',
|
||||
});
|
||||
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
|
||||
assert.equal(deps.getPlaybackPaused(), true);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
|
||||
@@ -101,20 +99,16 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
const cycles: Array<{ id: string; direction: 1 | -1 }> = [];
|
||||
registerIpcHandlers(
|
||||
{
|
||||
getInvisibleWindow: () => null,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
setInvisibleIgnoreMouseEvents: () => {},
|
||||
onOverlayModalClosed: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleDevTools: () => {},
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
toggleVisibleOverlay: () => {},
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getPlaybackPaused: () => null,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
@@ -138,7 +132,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
return { ok: true };
|
||||
},
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
@@ -160,7 +153,12 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
});
|
||||
const validResult = await setHandler!({}, 'anki.autoUpdateNewCards', true);
|
||||
assert.deepEqual(validResult, { ok: true });
|
||||
assert.deepEqual(calls, [{ id: 'anki.autoUpdateNewCards', value: true }]);
|
||||
const validSubtitleAnnotationResult = await setHandler!({}, 'subtitle.annotation.jlpt', false);
|
||||
assert.deepEqual(validSubtitleAnnotationResult, { ok: true });
|
||||
assert.deepEqual(calls, [
|
||||
{ id: 'anki.autoUpdateNewCards', value: true },
|
||||
{ id: 'subtitle.annotation.jlpt', value: false },
|
||||
]);
|
||||
|
||||
const cycleHandler = handlers.handle.get(IPC_CHANNELS.request.cycleRuntimeOption);
|
||||
assert.ok(cycleHandler);
|
||||
@@ -171,30 +169,34 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
});
|
||||
await cycleHandler!({}, 'anki.kikuFieldGrouping', -1);
|
||||
assert.deepEqual(cycles, [{ id: 'anki.kikuFieldGrouping', direction: -1 }]);
|
||||
|
||||
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
||||
assert.ok(getPlaybackPausedHandler);
|
||||
assert.equal(getPlaybackPausedHandler!({}), null);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const saves: unknown[] = [];
|
||||
const modals: unknown[] = [];
|
||||
const closedModals: unknown[] = [];
|
||||
const openedModals: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
{
|
||||
getInvisibleWindow: () => null,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
setInvisibleIgnoreMouseEvents: () => {},
|
||||
onOverlayModalClosed: (modal) => {
|
||||
modals.push(modal);
|
||||
closedModals.push(modal);
|
||||
},
|
||||
onOverlayModalOpened: (modal) => {
|
||||
openedModals.push(modal);
|
||||
},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleDevTools: () => {},
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
toggleVisibleOverlay: () => {},
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getPlaybackPaused: () => false,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: (position) => {
|
||||
@@ -214,7 +216,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
@@ -227,12 +228,15 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
|
||||
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 'bad' });
|
||||
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 });
|
||||
assert.deepEqual(saves, [
|
||||
{ yPercent: 42, invisibleOffsetXPx: undefined, invisibleOffsetYPx: undefined },
|
||||
]);
|
||||
assert.deepEqual(saves, [{ yPercent: 42 }]);
|
||||
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
|
||||
assert.deepEqual(modals, ['subsync', 'kiku']);
|
||||
assert.deepEqual(closedModals, ['subsync', 'kiku']);
|
||||
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
||||
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
|
||||
});
|
||||
|
||||
@@ -19,20 +19,17 @@ import {
|
||||
} from '../../shared/ipc/validators';
|
||||
|
||||
export interface IpcServiceDeps {
|
||||
getInvisibleWindow: () => WindowLike | null;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
toggleVisibleOverlay: () => void;
|
||||
getInvisibleOverlayVisibility: () => boolean;
|
||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||
getCurrentSubtitleRaw: () => string;
|
||||
getCurrentSubtitleAss: () => string;
|
||||
getMpvSubtitleRenderMetrics: () => unknown;
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getSubtitlePosition: () => unknown;
|
||||
getSubtitleStyle: () => unknown;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
@@ -54,7 +51,6 @@ export interface IpcServiceDeps {
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
@@ -91,18 +87,17 @@ interface IpcMainRegistrar {
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getInvisibleWindow: () => WindowLike | null;
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
getInvisibleOverlayVisibility: () => boolean;
|
||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||
getCurrentSubtitleRaw: () => string;
|
||||
getCurrentSubtitleAss: () => string;
|
||||
getMpvSubtitleRenderMetrics: () => unknown;
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getSubtitlePosition: () => unknown;
|
||||
getSubtitleStyle: () => unknown;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
@@ -119,7 +114,6 @@ export interface IpcDepsRuntimeOptions {
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
@@ -130,14 +124,8 @@ export interface IpcDepsRuntimeOptions {
|
||||
|
||||
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
|
||||
return {
|
||||
getInvisibleWindow: () => options.getInvisibleWindow(),
|
||||
isVisibleOverlayVisible: options.getVisibleOverlayVisibility,
|
||||
setInvisibleIgnoreMouseEvents: (ignore, eventsOptions) => {
|
||||
const invisibleWindow = options.getInvisibleWindow();
|
||||
if (!invisibleWindow || invisibleWindow.isDestroyed()) return;
|
||||
invisibleWindow.setIgnoreMouseEvents(ignore, eventsOptions);
|
||||
},
|
||||
onOverlayModalClosed: options.onOverlayModalClosed,
|
||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||
openYomitanSettings: options.openYomitanSettings,
|
||||
quitApp: options.quitApp,
|
||||
toggleDevTools: () => {
|
||||
@@ -147,11 +135,10 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
},
|
||||
getVisibleOverlayVisibility: options.getVisibleOverlayVisibility,
|
||||
toggleVisibleOverlay: options.toggleVisibleOverlay,
|
||||
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
|
||||
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
|
||||
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
||||
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
||||
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
|
||||
getPlaybackPaused: options.getPlaybackPaused,
|
||||
getSubtitlePosition: options.getSubtitlePosition,
|
||||
getSubtitleStyle: options.getSubtitleStyle,
|
||||
saveSubtitlePosition: options.saveSubtitlePosition,
|
||||
@@ -182,7 +169,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
setRuntimeOption: options.setRuntimeOption,
|
||||
cycleRuntimeOption: options.cycleRuntimeOption,
|
||||
reportOverlayContentBounds: options.reportOverlayContentBounds,
|
||||
reportHoveredSubtitleToken: options.reportHoveredSubtitleToken,
|
||||
getAnilistStatus: options.getAnilistStatus,
|
||||
clearAnilistToken: options.clearAnilistToken,
|
||||
openAnilistSetup: options.openAnilistSetup,
|
||||
@@ -200,17 +186,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
const parsedOptions = parseOptionalForwardingOptions(options);
|
||||
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
const invisibleWindow = deps.getInvisibleWindow();
|
||||
if (
|
||||
senderWindow === invisibleWindow &&
|
||||
deps.isVisibleOverlayVisible() &&
|
||||
invisibleWindow &&
|
||||
!invisibleWindow.isDestroyed()
|
||||
) {
|
||||
deps.setInvisibleIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
}
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -220,6 +196,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
if (!parsedModal) return;
|
||||
deps.onOverlayModalClosed(parsedModal);
|
||||
});
|
||||
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (_event: unknown, modal: unknown) => {
|
||||
const parsedModal = parseOverlayHostedModal(modal);
|
||||
if (!parsedModal) return;
|
||||
if (!deps.onOverlayModalOpened) return;
|
||||
deps.onOverlayModalOpened(parsedModal);
|
||||
});
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
||||
deps.openYomitanSettings();
|
||||
@@ -233,10 +215,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.toggleDevTools();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getOverlayVisibility, () => {
|
||||
return deps.getVisibleOverlayVisibility();
|
||||
});
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.toggleOverlay, () => {
|
||||
deps.toggleVisibleOverlay();
|
||||
});
|
||||
@@ -245,10 +223,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getVisibleOverlayVisibility();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getInvisibleOverlayVisibility, () => {
|
||||
return deps.getInvisibleOverlayVisibility();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => {
|
||||
return await deps.tokenizeCurrentSubtitle();
|
||||
});
|
||||
@@ -261,8 +235,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getCurrentSubtitleAss();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics, () => {
|
||||
return deps.getMpvSubtitleRenderMetrics();
|
||||
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
|
||||
return deps.getPlaybackPaused();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => {
|
||||
@@ -358,17 +332,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.reportOverlayContentBounds(payload);
|
||||
});
|
||||
|
||||
ipc.on('subtitle-token-hover:set', (_event: unknown, tokenIndex: unknown) => {
|
||||
if (tokenIndex === null) {
|
||||
deps.reportHoveredSubtitleToken(null);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(tokenIndex) || (tokenIndex as number) < 0) {
|
||||
return;
|
||||
}
|
||||
deps.reportHoveredSubtitleToken(tokenIndex as number);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
|
||||
return deps.getAnilistStatus();
|
||||
});
|
||||
|
||||
@@ -62,7 +62,8 @@ export function createJellyfinTokenStore(
|
||||
}
|
||||
const decrypted = safeStorage.decryptString(encrypted).trim();
|
||||
const session = JSON.parse(decrypted) as Partial<JellyfinStoredSession>;
|
||||
const accessToken = typeof session.accessToken === 'string' ? session.accessToken.trim() : '';
|
||||
const accessToken =
|
||||
typeof session.accessToken === 'string' ? session.accessToken.trim() : '';
|
||||
const userId = typeof session.userId === 'string' ? session.userId.trim() : '';
|
||||
if (!accessToken || !userId) return null;
|
||||
return { accessToken, userId };
|
||||
@@ -88,7 +89,9 @@ export function createJellyfinTokenStore(
|
||||
(typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) ||
|
||||
(typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0)
|
||||
) {
|
||||
logger.warn('Ignoring legacy Jellyfin token-only store payload because userId is missing.');
|
||||
logger.warn(
|
||||
'Ignoring legacy Jellyfin token-only store payload because userId is missing.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to read Jellyfin session store.', error);
|
||||
|
||||
@@ -60,6 +60,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'sub-use-margins',
|
||||
'pause',
|
||||
'media-title',
|
||||
'secondary-sub-visibility',
|
||||
'sub-visibility',
|
||||
];
|
||||
|
||||
const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
|
||||
|
||||
@@ -119,6 +119,36 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
isVisibleOverlayVisible: () => true,
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-visibility', data: 'yes' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.commands, [
|
||||
{
|
||||
command: ['set_property', 'sub-visibility', false],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage skips sub-visibility suppression when overlay is hidden', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
isVisibleOverlayVisible: () => false,
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-visibility', data: 'yes' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.equal(state.commands.length, 0);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
|
||||
@@ -216,6 +216,10 @@ export async function dispatchMpvProtocolMessage(
|
||||
deps.emitSubtitleMetricsChange({
|
||||
subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow),
|
||||
});
|
||||
} else if (msg.name === 'sub-visibility') {
|
||||
if (deps.isVisibleOverlayVisible() && asBoolean(msg.data, false)) {
|
||||
deps.sendCommand({ command: ['set_property', 'sub-visibility', false] });
|
||||
}
|
||||
} else if (msg.name === 'sub-use-margins') {
|
||||
deps.emitSubtitleMetricsChange({
|
||||
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),
|
||||
|
||||
@@ -13,7 +13,6 @@ function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClie
|
||||
getResolvedConfig: () => ({}) as any,
|
||||
autoStartOverlay: false,
|
||||
setOverlayVisible: () => {},
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
getReconnectTimer: () => null,
|
||||
setReconnectTimer: () => {},
|
||||
@@ -306,6 +305,54 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
|
||||
assert.equal(hasPathRequest, true);
|
||||
});
|
||||
|
||||
test('MpvIpcClient connect does not force primary subtitle visibility from binding path', () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
isVisibleOverlayVisible: () => true,
|
||||
}),
|
||||
);
|
||||
(client as any).send = (command: unknown) => {
|
||||
commands.push(command);
|
||||
return true;
|
||||
};
|
||||
|
||||
const callbacks = (client as any).transport.callbacks;
|
||||
callbacks.onConnect();
|
||||
|
||||
const hasPrimaryVisibilityMutation = commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command: unknown[] }).command) &&
|
||||
(command as { command: unknown[] }).command[0] === 'set_property' &&
|
||||
(command as { command: unknown[] }).command[1] === 'sub-visibility',
|
||||
);
|
||||
assert.equal(hasPrimaryVisibilityMutation, false);
|
||||
});
|
||||
|
||||
test('MpvIpcClient setSubVisibility writes compatibility commands for visibility toggle', () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).send = (payload: unknown) => {
|
||||
commands.push(payload);
|
||||
return true;
|
||||
};
|
||||
|
||||
client.setSubVisibility(false);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
{
|
||||
command: ['set_property', 'sub-visibility', false],
|
||||
},
|
||||
{
|
||||
command: ['set_property', 'sub-visibility', 'no'],
|
||||
},
|
||||
{
|
||||
command: ['set', 'sub-visibility', 'no'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface MpvRuntimeClientLike {
|
||||
replayCurrentSubtitle?: () => void;
|
||||
playNextSubtitle?: () => void;
|
||||
setSubVisibility?: (visible: boolean) => void;
|
||||
setSecondarySubVisibility?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export function showMpvOsdRuntime(
|
||||
@@ -84,13 +85,20 @@ export function setMpvSubVisibilityRuntime(
|
||||
mpvClient.setSubVisibility(visible);
|
||||
}
|
||||
|
||||
export function setMpvSecondarySubVisibilityRuntime(
|
||||
mpvClient: MpvRuntimeClientLike | null,
|
||||
visible: boolean,
|
||||
): void {
|
||||
if (!mpvClient?.setSecondarySubVisibility) return;
|
||||
mpvClient.setSecondarySubVisibility(visible);
|
||||
}
|
||||
|
||||
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from './mpv-protocol';
|
||||
|
||||
export interface MpvIpcClientProtocolDeps {
|
||||
getResolvedConfig: () => Config;
|
||||
autoStartOverlay: boolean;
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
@@ -181,8 +189,6 @@ export class MpvIpcClient implements MpvClient {
|
||||
setTimeout(() => {
|
||||
this.deps.setOverlayVisible(true);
|
||||
}, 100);
|
||||
} else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) {
|
||||
this.setSubVisibility(!this.deps.isVisibleOverlayVisible());
|
||||
}
|
||||
|
||||
this.firstConnection = false;
|
||||
@@ -464,8 +470,16 @@ export class MpvIpcClient implements MpvClient {
|
||||
}
|
||||
|
||||
setSubVisibility(visible: boolean): void {
|
||||
const value = visible ? 'yes' : 'no';
|
||||
this.send({
|
||||
command: ['set_property', 'sub-visibility', visible ? 'yes' : 'no'],
|
||||
command: ['set_property', 'sub-visibility', visible],
|
||||
});
|
||||
this.send({
|
||||
command: ['set_property', 'sub-visibility', value],
|
||||
});
|
||||
// Compatibility write for mpv command aliases across setups.
|
||||
this.send({
|
||||
command: ['set', 'sub-visibility', value],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -488,7 +502,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.previousSecondarySubVisibility = null;
|
||||
}
|
||||
|
||||
private setSecondarySubVisibility(visible: boolean): void {
|
||||
setSecondarySubVisibility(visible: boolean): void {
|
||||
this.send({
|
||||
command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'],
|
||||
});
|
||||
|
||||
@@ -33,13 +33,51 @@ test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden o
|
||||
assert.deepEqual(sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToVisibleOverlayRuntime waits for overlay page before sending open command', () => {
|
||||
const sent: unknown[][] = [];
|
||||
const restoreSet = new Set<'runtime-options' | 'subsync'>();
|
||||
let loading = true;
|
||||
let currentURL = '';
|
||||
const finishCallbacks: Array<() => void> = [];
|
||||
|
||||
const ok = sendToVisibleOverlayRuntime({
|
||||
mainWindow: {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isLoading: () => loading,
|
||||
getURL: () => currentURL,
|
||||
send: (...args: unknown[]) => {
|
||||
sent.push(args);
|
||||
},
|
||||
once: (_event: string, callback: () => void) => {
|
||||
finishCallbacks.push(callback);
|
||||
},
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow,
|
||||
visibleOverlayVisible: false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
channel: 'runtime-options:open',
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
restoreVisibleOverlayOnModalClose: restoreSet,
|
||||
});
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(restoreSet.has('runtime-options'), true);
|
||||
|
||||
loading = false;
|
||||
currentURL = 'file:///overlay/index.html?layer=visible';
|
||||
assert.ok(finishCallbacks[0]);
|
||||
finishCallbacks[0]!();
|
||||
|
||||
assert.deepEqual(sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => {
|
||||
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (next) => {
|
||||
resolver = next;
|
||||
|
||||
@@ -13,6 +13,11 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
|
||||
}): boolean {
|
||||
if (!options.mainWindow || options.mainWindow.isDestroyed()) return false;
|
||||
const wasVisible = options.visibleOverlayVisible;
|
||||
const webContents = options.mainWindow.webContents as Electron.WebContents & {
|
||||
isLoading?: () => boolean;
|
||||
getURL?: () => string;
|
||||
once?: (event: 'did-finish-load', listener: () => void) => void;
|
||||
};
|
||||
if (!options.visibleOverlayVisible) {
|
||||
options.setVisibleOverlayVisible(true);
|
||||
}
|
||||
@@ -21,32 +26,37 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
|
||||
}
|
||||
const sendNow = (): void => {
|
||||
if (options.payload === undefined) {
|
||||
options.mainWindow!.webContents.send(options.channel);
|
||||
webContents.send(options.channel);
|
||||
} else {
|
||||
options.mainWindow!.webContents.send(options.channel, options.payload);
|
||||
webContents.send(options.channel, options.payload);
|
||||
}
|
||||
};
|
||||
if (options.mainWindow.webContents.isLoading()) {
|
||||
options.mainWindow.webContents.once('did-finish-load', () => {
|
||||
if (
|
||||
options.mainWindow &&
|
||||
!options.mainWindow.isDestroyed() &&
|
||||
!options.mainWindow.webContents.isLoading()
|
||||
) {
|
||||
|
||||
const isLoading = typeof webContents.isLoading === 'function' ? webContents.isLoading() : false;
|
||||
const currentURL = typeof webContents.getURL === 'function' ? webContents.getURL() : '';
|
||||
const isReady = !isLoading && currentURL !== '' && currentURL !== 'about:blank';
|
||||
|
||||
if (!isReady) {
|
||||
if (typeof webContents.once !== 'function') {
|
||||
sendNow();
|
||||
return true;
|
||||
}
|
||||
webContents.once('did-finish-load', () => {
|
||||
if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
|
||||
if (typeof webContents.isLoading !== 'function' || !webContents.isLoading()) {
|
||||
sendNow();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
sendNow();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
sendToVisibleOverlay: (
|
||||
@@ -57,9 +67,7 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
return createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
|
||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
sendRequestToVisibleOverlay: (data) =>
|
||||
|
||||
@@ -28,7 +28,7 @@ test('sanitizeOverlayContentMeasurement accepts valid payload with null rect', (
|
||||
test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
|
||||
const measurement = sanitizeOverlayContentMeasurement(
|
||||
{
|
||||
layer: 'invisible',
|
||||
layer: 'visible',
|
||||
measuredAtMs: 100,
|
||||
viewport: { width: 0, height: 1080 },
|
||||
contentRect: { x: 0, y: 0, width: 100, height: 20 },
|
||||
@@ -39,7 +39,7 @@ test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
|
||||
assert.equal(measurement, null);
|
||||
});
|
||||
|
||||
test('overlay measurement store keeps latest payload per layer', () => {
|
||||
test('overlay measurement store keeps latest payload for visible layer', () => {
|
||||
const store = createOverlayContentMeasurementStore({
|
||||
now: () => 1000,
|
||||
warn: () => {
|
||||
@@ -53,17 +53,9 @@ test('overlay measurement store keeps latest payload per layer', () => {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
contentRect: { x: 50, y: 60, width: 400, height: 80 },
|
||||
});
|
||||
const invisible = store.report({
|
||||
layer: 'invisible',
|
||||
measuredAtMs: 910,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
contentRect: { x: 20, y: 30, width: 300, height: 40 },
|
||||
});
|
||||
|
||||
assert.equal(visible?.layer, 'visible');
|
||||
assert.equal(invisible?.layer, 'invisible');
|
||||
assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400);
|
||||
assert.equal(store.getLatestByLayer('invisible')?.contentRect?.height, 40);
|
||||
});
|
||||
|
||||
test('overlay measurement store rate-limits invalid payload warnings', () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ export function sanitizeOverlayContentMeasurement(
|
||||
} | null;
|
||||
};
|
||||
|
||||
if (candidate.layer !== 'visible' && candidate.layer !== 'invisible') {
|
||||
if (candidate.layer !== 'visible') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ export function createOverlayContentMeasurementStore(options?: {
|
||||
const warn = options?.warn ?? ((message: string) => logger.warn(message));
|
||||
const latestByLayer: OverlayMeasurementStore = {
|
||||
visible: null,
|
||||
invisible: null,
|
||||
};
|
||||
|
||||
let droppedInvalid = 0;
|
||||
|
||||
@@ -85,7 +85,9 @@ export function parseClipboardVideoPath(text: string): string | null {
|
||||
return isSupportedVideoPath(unquoted) ? unquoted : null;
|
||||
}
|
||||
|
||||
export function collectDroppedVideoPaths(dataTransfer: DropDataTransferLike | null | undefined): string[] {
|
||||
export function collectDroppedVideoPaths(
|
||||
dataTransfer: DropDataTransferLike | null | undefined,
|
||||
): string[] {
|
||||
if (!dataTransfer) return [];
|
||||
|
||||
const out: string[] = [];
|
||||
|
||||
@@ -9,11 +9,8 @@ import {
|
||||
test('overlay manager initializes with empty windows and hidden overlays', () => {
|
||||
const manager = createOverlayManager();
|
||||
assert.equal(manager.getMainWindow(), null);
|
||||
assert.equal(manager.getInvisibleWindow(), null);
|
||||
assert.equal(manager.getSecondaryWindow(), null);
|
||||
assert.equal(manager.getModalWindow(), null);
|
||||
assert.equal(manager.getVisibleOverlayVisible(), false);
|
||||
assert.equal(manager.getInvisibleOverlayVisible(), false);
|
||||
assert.deepEqual(manager.getOverlayWindows(), []);
|
||||
});
|
||||
|
||||
@@ -22,28 +19,17 @@ test('overlay manager stores window references and returns stable window order',
|
||||
const visibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const invisibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const secondaryWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const modalWindow = {
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
|
||||
manager.setMainWindow(visibleWindow);
|
||||
manager.setInvisibleWindow(invisibleWindow);
|
||||
manager.setSecondaryWindow(secondaryWindow);
|
||||
manager.setModalWindow(modalWindow);
|
||||
|
||||
assert.equal(manager.getMainWindow(), visibleWindow);
|
||||
assert.equal(manager.getInvisibleWindow(), invisibleWindow);
|
||||
assert.equal(manager.getSecondaryWindow(), secondaryWindow);
|
||||
assert.equal(manager.getModalWindow(), modalWindow);
|
||||
assert.equal(manager.getOverlayWindow('visible'), visibleWindow);
|
||||
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow);
|
||||
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]);
|
||||
assert.equal(manager.getOverlayWindow(), visibleWindow);
|
||||
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow]);
|
||||
});
|
||||
|
||||
test('overlay manager excludes destroyed windows', () => {
|
||||
@@ -51,26 +37,18 @@ test('overlay manager excludes destroyed windows', () => {
|
||||
manager.setMainWindow({
|
||||
isDestroyed: () => true,
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
manager.setInvisibleWindow({
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
manager.setSecondaryWindow({
|
||||
isDestroyed: () => true,
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
manager.setModalWindow({
|
||||
isDestroyed: () => false,
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
|
||||
assert.equal(manager.getOverlayWindows().length, 1);
|
||||
assert.equal(manager.getOverlayWindows().length, 0);
|
||||
});
|
||||
|
||||
test('overlay manager stores visibility state', () => {
|
||||
const manager = createOverlayManager();
|
||||
|
||||
manager.setVisibleOverlayVisible(true);
|
||||
manager.setInvisibleOverlayVisible(true);
|
||||
assert.equal(manager.getVisibleOverlayVisible(), true);
|
||||
assert.equal(manager.getInvisibleOverlayVisible(), true);
|
||||
});
|
||||
|
||||
test('overlay manager broadcasts to non-destroyed windows', () => {
|
||||
@@ -84,58 +62,25 @@ test('overlay manager broadcasts to non-destroyed windows', () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const deadWindow = {
|
||||
isDestroyed: () => true,
|
||||
webContents: {
|
||||
send: (..._args: unknown[]) => {},
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const secondaryWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
send: (...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
},
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
|
||||
manager.setMainWindow(aliveWindow);
|
||||
manager.setInvisibleWindow(deadWindow);
|
||||
manager.setSecondaryWindow(secondaryWindow);
|
||||
manager.setModalWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: () => {} },
|
||||
} as unknown as Electron.BrowserWindow);
|
||||
manager.broadcastToOverlayWindows('x', 1, 'a');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['x', 1, 'a'],
|
||||
['x', 1, 'a'],
|
||||
]);
|
||||
assert.deepEqual(calls, [['x', 1, 'a']]);
|
||||
});
|
||||
|
||||
test('overlay manager applies bounds by layer', () => {
|
||||
test('overlay manager applies bounds for main and modal windows', () => {
|
||||
const manager = createOverlayManager();
|
||||
const visibleCalls: Electron.Rectangle[] = [];
|
||||
const invisibleCalls: Electron.Rectangle[] = [];
|
||||
const visibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
setBounds: (bounds: Electron.Rectangle) => {
|
||||
visibleCalls.push(bounds);
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const invisibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
setBounds: (bounds: Electron.Rectangle) => {
|
||||
invisibleCalls.push(bounds);
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const secondaryWindow = {
|
||||
isDestroyed: () => false,
|
||||
setBounds: (bounds: Electron.Rectangle) => {
|
||||
invisibleCalls.push(bounds);
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
const modalCalls: Electron.Rectangle[] = [];
|
||||
const modalWindow = {
|
||||
isDestroyed: () => false,
|
||||
@@ -144,28 +89,14 @@ test('overlay manager applies bounds by layer', () => {
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
manager.setMainWindow(visibleWindow);
|
||||
manager.setInvisibleWindow(invisibleWindow);
|
||||
manager.setSecondaryWindow(secondaryWindow);
|
||||
manager.setModalWindow(modalWindow);
|
||||
|
||||
manager.setOverlayWindowBounds('visible', {
|
||||
manager.setOverlayWindowBounds({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
});
|
||||
manager.setOverlayWindowBounds('invisible', {
|
||||
x: 1,
|
||||
y: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
});
|
||||
manager.setSecondaryWindowBounds({
|
||||
x: 8,
|
||||
y: 9,
|
||||
width: 10,
|
||||
height: 11,
|
||||
});
|
||||
manager.setModalWindowBounds({
|
||||
x: 80,
|
||||
y: 90,
|
||||
@@ -174,14 +105,10 @@ test('overlay manager applies bounds by layer', () => {
|
||||
});
|
||||
|
||||
assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]);
|
||||
assert.deepEqual(invisibleCalls, [
|
||||
{ x: 1, y: 2, width: 3, height: 4 },
|
||||
{ x: 8, y: 9, width: 10, height: 11 },
|
||||
]);
|
||||
assert.deepEqual(modalCalls, [{ x: 80, y: 90, width: 100, height: 110 }]);
|
||||
});
|
||||
|
||||
test('runtime-option and debug broadcasts use expected channels', () => {
|
||||
test('runtime-option broadcast still uses expected channel', () => {
|
||||
const broadcasts: unknown[][] = [];
|
||||
broadcastRuntimeOptionsChangedRuntime(
|
||||
() => [],
|
||||
@@ -190,20 +117,10 @@ test('runtime-option and debug broadcasts use expected channels', () => {
|
||||
},
|
||||
);
|
||||
let state = false;
|
||||
const changed = setOverlayDebugVisualizationEnabledRuntime(
|
||||
state,
|
||||
true,
|
||||
(enabled) => {
|
||||
state = enabled;
|
||||
},
|
||||
(channel, ...args) => {
|
||||
broadcasts.push([channel, ...args]);
|
||||
},
|
||||
);
|
||||
const changed = setOverlayDebugVisualizationEnabledRuntime(state, true, (enabled) => {
|
||||
state = enabled;
|
||||
});
|
||||
assert.equal(changed, true);
|
||||
assert.equal(state, true);
|
||||
assert.deepEqual(broadcasts, [
|
||||
['runtime-options:changed', []],
|
||||
['overlay-debug-visualization:set', true],
|
||||
]);
|
||||
assert.deepEqual(broadcasts, [['runtime-options:changed', []]]);
|
||||
});
|
||||
|
||||
@@ -2,60 +2,37 @@ import type { BrowserWindow } from 'electron';
|
||||
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
||||
import { updateOverlayWindowBounds } from './overlay-window';
|
||||
|
||||
type OverlayLayer = 'visible' | 'invisible';
|
||||
|
||||
export interface OverlayManager {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
setMainWindow: (window: BrowserWindow | null) => void;
|
||||
getInvisibleWindow: () => BrowserWindow | null;
|
||||
setInvisibleWindow: (window: BrowserWindow | null) => void;
|
||||
getSecondaryWindow: () => BrowserWindow | null;
|
||||
setSecondaryWindow: (window: BrowserWindow | null) => void;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
setModalWindow: (window: BrowserWindow | null) => void;
|
||||
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
|
||||
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
|
||||
setSecondaryWindowBounds: (geometry: WindowGeometry) => void;
|
||||
getOverlayWindow: () => BrowserWindow | null;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createOverlayManager(): OverlayManager {
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let invisibleWindow: BrowserWindow | null = null;
|
||||
let secondaryWindow: BrowserWindow | null = null;
|
||||
let modalWindow: BrowserWindow | null = null;
|
||||
let visibleOverlayVisible = false;
|
||||
let invisibleOverlayVisible = false;
|
||||
|
||||
return {
|
||||
getMainWindow: () => mainWindow,
|
||||
setMainWindow: (window) => {
|
||||
mainWindow = window;
|
||||
},
|
||||
getInvisibleWindow: () => invisibleWindow,
|
||||
setInvisibleWindow: (window) => {
|
||||
invisibleWindow = window;
|
||||
},
|
||||
getSecondaryWindow: () => secondaryWindow,
|
||||
setSecondaryWindow: (window) => {
|
||||
secondaryWindow = window;
|
||||
},
|
||||
getModalWindow: () => modalWindow,
|
||||
setModalWindow: (window) => {
|
||||
modalWindow = window;
|
||||
},
|
||||
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow),
|
||||
setOverlayWindowBounds: (layer, geometry) => {
|
||||
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow);
|
||||
},
|
||||
setSecondaryWindowBounds: (geometry) => {
|
||||
updateOverlayWindowBounds(geometry, secondaryWindow);
|
||||
getOverlayWindow: () => mainWindow,
|
||||
setOverlayWindowBounds: (geometry) => {
|
||||
updateOverlayWindowBounds(geometry, mainWindow);
|
||||
},
|
||||
setModalWindowBounds: (geometry) => {
|
||||
updateOverlayWindowBounds(geometry, modalWindow);
|
||||
@@ -64,36 +41,12 @@ export function createOverlayManager(): OverlayManager {
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlayVisible = visible;
|
||||
},
|
||||
getInvisibleOverlayVisible: () => invisibleOverlayVisible,
|
||||
setInvisibleOverlayVisible: (visible) => {
|
||||
invisibleOverlayVisible = visible;
|
||||
},
|
||||
getOverlayWindows: () => {
|
||||
const windows: BrowserWindow[] = [];
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
windows.push(mainWindow);
|
||||
}
|
||||
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
|
||||
windows.push(invisibleWindow);
|
||||
}
|
||||
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
|
||||
windows.push(secondaryWindow);
|
||||
}
|
||||
return windows;
|
||||
return mainWindow && !mainWindow.isDestroyed() ? [mainWindow] : [];
|
||||
},
|
||||
broadcastToOverlayWindows: (channel, ...args) => {
|
||||
const windows: BrowserWindow[] = [];
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
windows.push(mainWindow);
|
||||
}
|
||||
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
|
||||
windows.push(invisibleWindow);
|
||||
}
|
||||
if (secondaryWindow && !secondaryWindow.isDestroyed()) {
|
||||
windows.push(secondaryWindow);
|
||||
}
|
||||
for (const window of windows) {
|
||||
window.webContents.send(channel, ...args);
|
||||
mainWindow.webContents.send(channel, ...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -110,10 +63,8 @@ export function setOverlayDebugVisualizationEnabledRuntime(
|
||||
currentEnabled: boolean,
|
||||
nextEnabled: boolean,
|
||||
setState: (enabled: boolean) => void,
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
|
||||
): boolean {
|
||||
if (currentEnabled === nextEnabled) return false;
|
||||
setState(nextEnabled);
|
||||
broadcastToOverlayWindows('overlay-debug-visualization:set', nextEnabled);
|
||||
return true;
|
||||
}
|
||||
|
||||
110
src/core/services/overlay-runtime-init.test.ts
Normal file
110
src/core/services/overlay-runtime-init.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
|
||||
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => false,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => null,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: () => {
|
||||
createdIntegrations += 1;
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(createdIntegrations, 0);
|
||||
assert.equal(startedIntegrations, 0);
|
||||
assert.equal(setIntegrationCalls, 0);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled is true', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => false,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => null,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: true } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: (args) => {
|
||||
createdIntegrations += 1;
|
||||
assert.equal(args.config.enabled, true);
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 3,
|
||||
deleteNoteId: 4,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(createdIntegrations, 1);
|
||||
assert.equal(startedIntegrations, 1);
|
||||
assert.equal(setIntegrationCalls, 1);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { AnkiIntegration } from '../../anki-integration';
|
||||
import { BaseWindowTracker, createWindowTracker } from '../../window-trackers';
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
@@ -8,21 +7,55 @@ import {
|
||||
WindowGeometry,
|
||||
} from '../../types';
|
||||
|
||||
type AnkiIntegrationLike = {
|
||||
start: () => void;
|
||||
};
|
||||
|
||||
type CreateAnkiIntegrationArgs = {
|
||||
config: AnkiConnectConfig;
|
||||
subtitleTimingTracker: unknown;
|
||||
mpvClient: { send?: (payload: { command: string[] }) => void };
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
knownWordCacheStatePath: string;
|
||||
};
|
||||
|
||||
function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiIntegrationLike {
|
||||
const { AnkiIntegration } =
|
||||
require('../../anki-integration') as typeof import('../../anki-integration');
|
||||
return new AnkiIntegration(
|
||||
args.config,
|
||||
args.subtitleTimingTracker as never,
|
||||
args.mpvClient as never,
|
||||
(text: string) => {
|
||||
if (args.mpvClient && typeof args.mpvClient.send === 'function') {
|
||||
args.mpvClient.send({
|
||||
command: ['show-text', text, '3000'],
|
||||
});
|
||||
}
|
||||
},
|
||||
args.showDesktopNotification,
|
||||
args.createFieldGroupingCallback(),
|
||||
args.knownWordCacheStatePath,
|
||||
);
|
||||
}
|
||||
|
||||
export function initializeOverlayRuntime(options: {
|
||||
backendOverride: string | null;
|
||||
getInitialInvisibleOverlayVisibility: () => boolean;
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
isInvisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
createWindowTracker?: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
getMpvSocketPath: () => string;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
@@ -38,30 +71,26 @@ export function initializeOverlayRuntime(options: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
}): {
|
||||
invisibleOverlayVisible: boolean;
|
||||
} {
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
}): void {
|
||||
options.createMainWindow();
|
||||
options.createInvisibleWindow();
|
||||
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility();
|
||||
options.registerGlobalShortcuts();
|
||||
|
||||
const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath());
|
||||
const createWindowTrackerHandler = options.createWindowTracker ?? createWindowTracker;
|
||||
const windowTracker = createWindowTrackerHandler(
|
||||
options.backendOverride,
|
||||
options.getMpvSocketPath(),
|
||||
);
|
||||
options.setWindowTracker(windowTracker);
|
||||
if (windowTracker) {
|
||||
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
options.updateInvisibleOverlayBounds(geometry);
|
||||
};
|
||||
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
options.updateInvisibleOverlayBounds(geometry);
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
options.updateVisibleOverlayVisibility();
|
||||
}
|
||||
if (options.isInvisibleOverlayVisible()) {
|
||||
options.updateInvisibleOverlayVisibility();
|
||||
}
|
||||
};
|
||||
windowTracker.onWindowLost = () => {
|
||||
for (const window of options.getOverlayWindows()) {
|
||||
@@ -77,31 +106,27 @@ export function initializeOverlayRuntime(options: {
|
||||
const mpvClient = options.getMpvClient();
|
||||
const runtimeOptionsManager = options.getRuntimeOptionsManager();
|
||||
|
||||
if (config.ankiConnect && subtitleTimingTracker && mpvClient && runtimeOptionsManager) {
|
||||
if (
|
||||
config.ankiConnect?.enabled === true &&
|
||||
subtitleTimingTracker &&
|
||||
mpvClient &&
|
||||
runtimeOptionsManager
|
||||
) {
|
||||
const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig(
|
||||
config.ankiConnect,
|
||||
);
|
||||
const integration = new AnkiIntegration(
|
||||
effectiveAnkiConfig,
|
||||
subtitleTimingTracker as never,
|
||||
mpvClient as never,
|
||||
(text: string) => {
|
||||
if (mpvClient && typeof mpvClient.send === 'function') {
|
||||
mpvClient.send({
|
||||
command: ['show-text', text, '3000'],
|
||||
});
|
||||
}
|
||||
},
|
||||
options.showDesktopNotification,
|
||||
options.createFieldGroupingCallback(),
|
||||
options.getKnownWordCacheStatePath(),
|
||||
);
|
||||
const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration;
|
||||
const integration = createAnkiIntegration({
|
||||
config: effectiveAnkiConfig,
|
||||
subtitleTimingTracker,
|
||||
mpvClient,
|
||||
showDesktopNotification: options.showDesktopNotification,
|
||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||
});
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
}
|
||||
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.updateInvisibleOverlayVisibility();
|
||||
|
||||
return { invisibleOverlayVisible };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||
return {
|
||||
toggleVisibleOverlayGlobal: null,
|
||||
toggleInvisibleOverlayGlobal: null,
|
||||
copySubtitle: null,
|
||||
copySubtitleMultiple: null,
|
||||
updateLastCardFromClipboard: null,
|
||||
|
||||
265
src/core/services/overlay-visibility.test.ts
Normal file
265
src/core/services/overlay-visibility.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
||||
|
||||
type WindowTrackerStub = {
|
||||
isTracking: () => boolean;
|
||||
getGeometry: () => { x: number; y: number; width: number; height: number } | null;
|
||||
};
|
||||
|
||||
function createMainWindowRecorder() {
|
||||
const calls: string[] = [];
|
||||
const window = {
|
||||
isDestroyed: () => false,
|
||||
hide: () => {
|
||||
calls.push('hide');
|
||||
},
|
||||
show: () => {
|
||||
calls.push('show');
|
||||
},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
setIgnoreMouseEvents: () => {
|
||||
calls.push('mouse-ignore');
|
||||
},
|
||||
};
|
||||
|
||||
return { window, calls };
|
||||
}
|
||||
|
||||
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const osdMessages: string[] = [];
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
};
|
||||
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
} as never);
|
||||
|
||||
run();
|
||||
run();
|
||||
|
||||
assert.equal(trackerWarning, true);
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...']);
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
|
||||
test('non-macOS keeps fallback visible overlay behavior when tracker is not ready', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
showOverlayLoadingOsd: () => {
|
||||
calls.push('osd');
|
||||
},
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
|
||||
assert.equal(trackerWarning, true);
|
||||
assert.ok(calls.includes('update-bounds'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(calls.includes('focus'));
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const osdMessages: string[] = [];
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: null,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(trackerWarning, true);
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...']);
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
});
|
||||
|
||||
test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly', () => {
|
||||
const calls: string[] = [];
|
||||
setVisibleOverlayVisible({
|
||||
visible: true,
|
||||
setVisibleOverlayVisibleState: (visible) => {
|
||||
calls.push(`state:${visible}`);
|
||||
},
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('update');
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['state:true', 'update']);
|
||||
});
|
||||
|
||||
test('macOS loading OSD can show again after overlay is hidden and retried', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
let trackerWarning = false;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: null,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
calls.push(`warn:${shown ? 'yes' : 'no'}`);
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
} as never);
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: false,
|
||||
mainWindow: window as never,
|
||||
windowTracker: null,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
calls.push(`warn:${shown ? 'yes' : 'no'}`);
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
} as never);
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: null,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
calls.push(`warn:${shown ? 'yes' : 'no'}`);
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, screen } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { BaseWindowTracker } from '../../window-trackers';
|
||||
import { WindowGeometry } from '../../types';
|
||||
|
||||
@@ -10,14 +10,19 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform?: boolean;
|
||||
showOverlayLoadingOsd?: (message: string) => void;
|
||||
resolveFallbackBounds?: () => WindowGeometry;
|
||||
}): void {
|
||||
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.visibleOverlayVisible) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
@@ -29,6 +34,8 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
if (geometry) {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
@@ -38,7 +45,18 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
|
||||
if (!args.windowTracker) {
|
||||
if (args.isMacOSPlatform) {
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
}
|
||||
args.mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
@@ -49,16 +67,23 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
if (args.isMacOSPlatform) {
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
}
|
||||
}
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const display = screen.getDisplayNearestPoint(cursorPoint);
|
||||
const fallbackBounds = display.workArea;
|
||||
args.updateVisibleOverlayBounds({
|
||||
x: fallbackBounds.x,
|
||||
y: fallbackBounds.y,
|
||||
width: fallbackBounds.width,
|
||||
height: fallbackBounds.height,
|
||||
});
|
||||
|
||||
if (args.isMacOSPlatform) {
|
||||
args.mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackBounds = args.resolveFallbackBounds?.();
|
||||
if (!fallbackBounds) return;
|
||||
|
||||
args.updateVisibleOverlayBounds(fallbackBounds);
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
@@ -66,111 +91,11 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.syncOverlayShortcuts();
|
||||
}
|
||||
|
||||
export function updateInvisibleOverlayVisibility(args: {
|
||||
invisibleWindow: BrowserWindow | null;
|
||||
visibleOverlayVisible: boolean;
|
||||
invisibleOverlayVisible: boolean;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
}): void {
|
||||
if (!args.invisibleWindow || args.invisibleWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.visibleOverlayVisible) {
|
||||
args.invisibleWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const showInvisibleWithoutFocus = (): void => {
|
||||
args.ensureOverlayWindowLevel(args.invisibleWindow!);
|
||||
if (typeof args.invisibleWindow!.showInactive === 'function') {
|
||||
args.invisibleWindow!.showInactive();
|
||||
} else {
|
||||
args.invisibleWindow!.show();
|
||||
}
|
||||
args.enforceOverlayLayerOrder();
|
||||
};
|
||||
|
||||
if (!args.invisibleOverlayVisible) {
|
||||
args.invisibleWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.windowTracker && args.windowTracker.isTracking()) {
|
||||
const geometry = args.windowTracker.getGeometry();
|
||||
if (geometry) {
|
||||
args.updateInvisibleOverlayBounds(geometry);
|
||||
}
|
||||
showInvisibleWithoutFocus();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.windowTracker) {
|
||||
showInvisibleWithoutFocus();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const display = screen.getDisplayNearestPoint(cursorPoint);
|
||||
const fallbackBounds = display.workArea;
|
||||
args.updateInvisibleOverlayBounds({
|
||||
x: fallbackBounds.x,
|
||||
y: fallbackBounds.y,
|
||||
width: fallbackBounds.width,
|
||||
height: fallbackBounds.height,
|
||||
});
|
||||
showInvisibleWithoutFocus();
|
||||
args.syncOverlayShortcuts();
|
||||
}
|
||||
|
||||
export function syncInvisibleOverlayMousePassthrough(options: {
|
||||
hasInvisibleWindow: () => boolean;
|
||||
setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void;
|
||||
visibleOverlayVisible: boolean;
|
||||
invisibleOverlayVisible: boolean;
|
||||
}): void {
|
||||
if (!options.hasInvisibleWindow()) return;
|
||||
if (options.visibleOverlayVisible) {
|
||||
options.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else if (options.invisibleOverlayVisible) {
|
||||
options.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function setVisibleOverlayVisible(options: {
|
||||
visible: boolean;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncInvisibleOverlayMousePassthrough: () => void;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||
isMpvConnected: () => boolean;
|
||||
setMpvSubVisibility: (visible: boolean) => void;
|
||||
}): void {
|
||||
options.setVisibleOverlayVisibleState(options.visible);
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.updateInvisibleOverlayVisibility();
|
||||
options.syncInvisibleOverlayMousePassthrough();
|
||||
if (options.shouldBindVisibleOverlayToMpvSubVisibility() && options.isMpvConnected()) {
|
||||
options.setMpvSubVisibility(!options.visible);
|
||||
}
|
||||
}
|
||||
|
||||
export function setInvisibleOverlayVisible(options: {
|
||||
visible: boolean;
|
||||
setInvisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncInvisibleOverlayMousePassthrough: () => void;
|
||||
}): void {
|
||||
options.setInvisibleOverlayVisibleState(options.visible);
|
||||
options.updateInvisibleOverlayVisibility();
|
||||
options.syncInvisibleOverlayMousePassthrough();
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { WindowGeometry } from '../../types';
|
||||
|
||||
export const SECONDARY_OVERLAY_MAX_HEIGHT_RATIO = 0.2;
|
||||
|
||||
function toInteger(value: number): number {
|
||||
return Number.isFinite(value) ? Math.round(value) : 0;
|
||||
}
|
||||
|
||||
function clampPositive(value: number): number {
|
||||
return Math.max(1, toInteger(value));
|
||||
}
|
||||
|
||||
export function splitOverlayGeometryForSecondaryBar(geometry: WindowGeometry): {
|
||||
secondary: WindowGeometry;
|
||||
primary: WindowGeometry;
|
||||
} {
|
||||
const x = toInteger(geometry.x);
|
||||
const y = toInteger(geometry.y);
|
||||
const width = clampPositive(geometry.width);
|
||||
const totalHeight = clampPositive(geometry.height);
|
||||
|
||||
const secondaryHeight = clampPositive(
|
||||
Math.min(totalHeight, Math.round(totalHeight * SECONDARY_OVERLAY_MAX_HEIGHT_RATIO)),
|
||||
);
|
||||
const primaryHeight = clampPositive(totalHeight - secondaryHeight);
|
||||
|
||||
return {
|
||||
secondary: {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: secondaryHeight,
|
||||
},
|
||||
primary: {
|
||||
x,
|
||||
y: y + secondaryHeight,
|
||||
width,
|
||||
height: primaryHeight,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { splitOverlayGeometryForSecondaryBar } from './overlay-window-geometry';
|
||||
|
||||
test('splitOverlayGeometryForSecondaryBar returns 20/80 top-bottom regions', () => {
|
||||
const regions = splitOverlayGeometryForSecondaryBar({
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
});
|
||||
|
||||
assert.deepEqual(regions.secondary, {
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 1200,
|
||||
height: 180,
|
||||
});
|
||||
assert.deepEqual(regions.primary, {
|
||||
x: 100,
|
||||
y: 230,
|
||||
width: 1200,
|
||||
height: 720,
|
||||
});
|
||||
});
|
||||
|
||||
test('splitOverlayGeometryForSecondaryBar keeps positive sizes for tiny heights', () => {
|
||||
const regions = splitOverlayGeometryForSecondaryBar({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
assert.ok(regions.secondary.height >= 1);
|
||||
assert.ok(regions.primary.height >= 1);
|
||||
});
|
||||
@@ -4,8 +4,25 @@ import { WindowGeometry } from '../../types';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
const logger = createLogger('main:overlay-window');
|
||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||
|
||||
export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal';
|
||||
function getOverlayWindowHtmlPath(): string {
|
||||
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
|
||||
}
|
||||
|
||||
function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind): void {
|
||||
overlayWindowLayerByInstance.set(window, layer);
|
||||
const htmlPath = getOverlayWindowHtmlPath();
|
||||
window
|
||||
.loadFile(htmlPath, {
|
||||
query: { layer },
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to load HTML file:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export type OverlayWindowKind = 'visible' | 'modal';
|
||||
|
||||
export function updateOverlayWindowBounds(
|
||||
geometry: WindowGeometry,
|
||||
@@ -32,14 +49,11 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
||||
|
||||
export function enforceOverlayLayerOrder(options: {
|
||||
visibleOverlayVisible: boolean;
|
||||
invisibleOverlayVisible: boolean;
|
||||
mainWindow: BrowserWindow | null;
|
||||
invisibleWindow: BrowserWindow | null;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
}): void {
|
||||
if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return;
|
||||
if (!options.visibleOverlayVisible) return;
|
||||
if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
|
||||
if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return;
|
||||
|
||||
options.ensureOverlayWindowLevel(options.mainWindow);
|
||||
options.mainWindow.moveTop();
|
||||
@@ -49,7 +63,6 @@ export function createOverlayWindow(
|
||||
kind: OverlayWindowKind,
|
||||
options: {
|
||||
isDev: boolean;
|
||||
overlayDebugVisualizationEnabled: boolean;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
@@ -83,16 +96,7 @@ export function createOverlayWindow(
|
||||
});
|
||||
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
|
||||
const htmlPath = path.join(__dirname, '..', '..', 'renderer', 'index.html');
|
||||
|
||||
window
|
||||
.loadFile(htmlPath, {
|
||||
query: { layer: kind },
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to load HTML file:', err);
|
||||
});
|
||||
loadOverlayWindowLayer(window, kind);
|
||||
|
||||
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||
logger.error('Page failed to load:', errorCode, errorDescription, validatedURL);
|
||||
@@ -100,10 +104,6 @@ export function createOverlayWindow(
|
||||
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
options.onRuntimeOptionsChanged();
|
||||
window.webContents.send(
|
||||
'overlay-debug-visualization:set',
|
||||
options.overlayDebugVisualizationEnabled,
|
||||
);
|
||||
});
|
||||
|
||||
if (kind === 'visible') {
|
||||
@@ -117,7 +117,7 @@ export function createOverlayWindow(
|
||||
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
if (kind === 'modal') return;
|
||||
if (!options.isOverlayVisible(kind)) return;
|
||||
if (!window.isVisible()) return;
|
||||
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
@@ -140,3 +140,9 @@ export function createOverlayWindow(
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'): void {
|
||||
if (window.isDestroyed()) return;
|
||||
if (overlayWindowLayerByInstance.get(window) === layer) return;
|
||||
loadOverlayWindowLayer(window, layer);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getInitialInvisibleOverlayVisibility,
|
||||
isAutoUpdateEnabledRuntime,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
shouldBindVisibleOverlayToMpvSubVisibility,
|
||||
} from './startup';
|
||||
|
||||
const BASE_CONFIG = {
|
||||
auto_start_overlay: false,
|
||||
bind_visible_overlay_to_mpv_sub_visibility: true,
|
||||
invisibleOverlay: {
|
||||
startupVisibility: 'platform-default' as const,
|
||||
},
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
autoUpdateNewCards: true,
|
||||
@@ -20,26 +14,7 @@ const BASE_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
test('getInitialInvisibleOverlayVisibility handles visibility + platform', () => {
|
||||
assert.equal(
|
||||
getInitialInvisibleOverlayVisibility(
|
||||
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'visible' } },
|
||||
'linux',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getInitialInvisibleOverlayVisibility(
|
||||
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'hidden' } },
|
||||
'darwin',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'linux'), false);
|
||||
assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'darwin'), true);
|
||||
});
|
||||
|
||||
test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visible startup', () => {
|
||||
test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start', () => {
|
||||
assert.equal(shouldAutoInitializeOverlayRuntimeFromConfig(BASE_CONFIG), false);
|
||||
assert.equal(
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig({
|
||||
@@ -48,24 +23,6 @@ test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visib
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig({
|
||||
...BASE_CONFIG,
|
||||
invisibleOverlay: { startupVisibility: 'visible' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldBindVisibleOverlayToMpvSubVisibility returns config value', () => {
|
||||
assert.equal(shouldBindVisibleOverlayToMpvSubVisibility(BASE_CONFIG), true);
|
||||
assert.equal(
|
||||
shouldBindVisibleOverlayToMpvSubVisibility({
|
||||
...BASE_CONFIG,
|
||||
bind_visible_overlay_to_mpv_sub_visibility: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isAutoUpdateEnabledRuntime prefers runtime option and falls back to config', () => {
|
||||
|
||||
@@ -5,14 +5,12 @@ const logger = createLogger('main:shortcut');
|
||||
|
||||
export interface GlobalShortcutConfig {
|
||||
toggleVisibleOverlayGlobal: string | null | undefined;
|
||||
toggleInvisibleOverlayGlobal: string | null | undefined;
|
||||
openJimaku?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface RegisterGlobalShortcutsServiceOptions {
|
||||
shortcuts: GlobalShortcutConfig;
|
||||
onToggleVisibleOverlay: () => void;
|
||||
onToggleInvisibleOverlay: () => void;
|
||||
onOpenYomitanSettings: () => void;
|
||||
onOpenJimaku?: () => void;
|
||||
isDev: boolean;
|
||||
@@ -21,9 +19,7 @@ export interface RegisterGlobalShortcutsServiceOptions {
|
||||
|
||||
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
|
||||
const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal;
|
||||
const invisibleShortcut = options.shortcuts.toggleInvisibleOverlayGlobal;
|
||||
const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase();
|
||||
const normalizedInvisible = invisibleShortcut?.replace(/\s+/g, '').toLowerCase();
|
||||
const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase();
|
||||
const normalizedSettings = 'alt+shift+y';
|
||||
|
||||
@@ -38,31 +34,10 @@ export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceO
|
||||
}
|
||||
}
|
||||
|
||||
if (invisibleShortcut && normalizedInvisible && normalizedInvisible !== normalizedVisible) {
|
||||
const toggleInvisibleRegistered = globalShortcut.register(invisibleShortcut, () => {
|
||||
options.onToggleInvisibleOverlay();
|
||||
});
|
||||
if (!toggleInvisibleRegistered) {
|
||||
logger.warn(
|
||||
`Failed to register global shortcut toggleInvisibleOverlayGlobal: ${invisibleShortcut}`,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
invisibleShortcut &&
|
||||
normalizedInvisible &&
|
||||
normalizedInvisible === normalizedVisible
|
||||
) {
|
||||
logger.warn(
|
||||
'Skipped registering toggleInvisibleOverlayGlobal because it collides with toggleVisibleOverlayGlobal',
|
||||
);
|
||||
}
|
||||
|
||||
if (options.shortcuts.openJimaku && options.onOpenJimaku) {
|
||||
if (
|
||||
normalizedJimaku &&
|
||||
(normalizedJimaku === normalizedVisible ||
|
||||
normalizedJimaku === normalizedInvisible ||
|
||||
normalizedJimaku === normalizedSettings)
|
||||
(normalizedJimaku === normalizedVisible || normalizedJimaku === normalizedSettings)
|
||||
) {
|
||||
logger.warn(
|
||||
'Skipped registering openJimaku because it collides with another global shortcut',
|
||||
|
||||
@@ -10,14 +10,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
toggleInvisibleOverlay: false,
|
||||
settings: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
hideVisibleOverlay: false,
|
||||
showInvisibleOverlay: false,
|
||||
hideInvisibleOverlay: false,
|
||||
copySubtitle: false,
|
||||
copySubtitleMultiple: false,
|
||||
mineSentence: false,
|
||||
|
||||
@@ -18,10 +18,6 @@ interface RuntimeAutoUpdateOptionManagerLike {
|
||||
|
||||
export interface RuntimeConfigLike {
|
||||
auto_start_overlay?: boolean;
|
||||
bind_visible_overlay_to_mpv_sub_visibility: boolean;
|
||||
invisibleOverlay: {
|
||||
startupVisibility: 'visible' | 'hidden' | 'platform-default';
|
||||
};
|
||||
ankiConnect?: {
|
||||
behavior?: {
|
||||
autoUpdateNewCards?: boolean;
|
||||
@@ -125,6 +121,7 @@ export interface AppReadyRuntimeDeps {
|
||||
logDebug?: (message: string) => void;
|
||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
||||
now?: () => number;
|
||||
shouldSkipHeavyStartup?: () => boolean;
|
||||
}
|
||||
|
||||
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
|
||||
@@ -155,25 +152,8 @@ function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function getInitialInvisibleOverlayVisibility(
|
||||
config: RuntimeConfigLike,
|
||||
platform: NodeJS.Platform,
|
||||
): boolean {
|
||||
const visibility = config.invisibleOverlay.startupVisibility;
|
||||
if (visibility === 'visible') return true;
|
||||
if (visibility === 'hidden') return false;
|
||||
if (platform === 'linux') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean {
|
||||
if (config.auto_start_overlay === true) return true;
|
||||
if (config.invisibleOverlay.startupVisibility === 'visible') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldBindVisibleOverlayToMpvSubVisibility(config: RuntimeConfigLike): boolean {
|
||||
return config.bind_visible_overlay_to_mpv_sub_visibility;
|
||||
return config.auto_start_overlay === true;
|
||||
}
|
||||
|
||||
export function isAutoUpdateEnabledRuntime(
|
||||
@@ -188,8 +168,21 @@ export function isAutoUpdateEnabledRuntime(
|
||||
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const startupStartedAtMs = now();
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
deps.handleInitialArgs();
|
||||
return;
|
||||
}
|
||||
|
||||
deps.logDebug?.('App-ready critical path started.');
|
||||
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
deps.handleInitialArgs();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
return;
|
||||
}
|
||||
|
||||
deps.reloadConfig();
|
||||
const config = deps.getResolvedConfig();
|
||||
const criticalConfigErrors = getStartupCriticalConfigErrors(config);
|
||||
@@ -205,6 +198,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
for (const warning of deps.getConfigWarnings()) {
|
||||
deps.logConfigWarning(warning);
|
||||
}
|
||||
deps.startBackgroundWarmups();
|
||||
|
||||
deps.loadSubtitlePosition();
|
||||
deps.resolveKeybindings();
|
||||
@@ -224,14 +218,9 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
deps.createSubtitleTimingTracker();
|
||||
if (deps.createImmersionTracker) {
|
||||
deps.log('Runtime ready: invoking createImmersionTracker.');
|
||||
try {
|
||||
deps.createImmersionTracker();
|
||||
} catch (error) {
|
||||
deps.log(`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`);
|
||||
}
|
||||
deps.log('Runtime ready: immersion tracker startup deferred until first media activity.');
|
||||
} else {
|
||||
deps.log('Runtime ready: createImmersionTracker dependency is missing.');
|
||||
deps.log('Runtime ready: immersion tracker dependency is missing.');
|
||||
}
|
||||
|
||||
if (deps.texthookerOnlyMode) {
|
||||
@@ -243,6 +232,5 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
}
|
||||
|
||||
deps.handleInitialArgs();
|
||||
deps.startBackgroundWarmups();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
}
|
||||
|
||||
@@ -101,20 +101,7 @@ export function loadSubtitlePosition(
|
||||
const data = fs.readFileSync(positionPath, 'utf-8');
|
||||
const parsed = JSON.parse(data) as Partial<SubtitlePosition>;
|
||||
if (parsed && typeof parsed.yPercent === 'number' && Number.isFinite(parsed.yPercent)) {
|
||||
const position: SubtitlePosition = { yPercent: parsed.yPercent };
|
||||
if (
|
||||
typeof parsed.invisibleOffsetXPx === 'number' &&
|
||||
Number.isFinite(parsed.invisibleOffsetXPx)
|
||||
) {
|
||||
position.invisibleOffsetXPx = parsed.invisibleOffsetXPx;
|
||||
}
|
||||
if (
|
||||
typeof parsed.invisibleOffsetYPx === 'number' &&
|
||||
Number.isFinite(parsed.invisibleOffsetYPx)
|
||||
) {
|
||||
position.invisibleOffsetYPx = parsed.invisibleOffsetYPx;
|
||||
}
|
||||
return position;
|
||||
return { yPercent: parsed.yPercent };
|
||||
}
|
||||
return options.fallbackPosition;
|
||||
} catch (err) {
|
||||
|
||||
@@ -64,6 +64,32 @@ test('subtitle processing skips duplicate subtitle emission', async () => {
|
||||
assert.equal(tokenizeCalls, 1);
|
||||
});
|
||||
|
||||
test('subtitle processing reuses cached tokenization for repeated subtitle text', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let tokenizeCalls = 0;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizeCalls += 1;
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('first');
|
||||
await flushMicrotasks();
|
||||
controller.onSubtitleChange('second');
|
||||
await flushMicrotasks();
|
||||
controller.onSubtitleChange('first');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 2);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'first', tokens: [] },
|
||||
{ text: 'second', tokens: [] },
|
||||
{ text: 'first', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle processing falls back to plain subtitle when tokenization returns null', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
@@ -112,3 +138,35 @@ test('subtitle processing refresh can use explicit text override', async () => {
|
||||
|
||||
assert.deepEqual(emitted, [{ text: 'initial', tokens: [] }]);
|
||||
});
|
||||
|
||||
test('subtitle processing cache invalidation only affects future subtitle events', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const callsByText = new Map<string, number>();
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
callsByText.set(text, (callsByText.get(text) ?? 0) + 1);
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('same');
|
||||
await flushMicrotasks();
|
||||
controller.onSubtitleChange('other');
|
||||
await flushMicrotasks();
|
||||
controller.onSubtitleChange('same');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(callsByText.get('same'), 1);
|
||||
assert.equal(emitted.length, 3);
|
||||
|
||||
controller.invalidateTokenizationCache();
|
||||
assert.equal(emitted.length, 3);
|
||||
|
||||
controller.onSubtitleChange('different');
|
||||
await flushMicrotasks();
|
||||
controller.onSubtitleChange('same');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(callsByText.get('same'), 2);
|
||||
});
|
||||
|
||||
@@ -10,18 +10,42 @@ export interface SubtitleProcessingControllerDeps {
|
||||
export interface SubtitleProcessingController {
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (textOverride?: string) => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
}
|
||||
|
||||
export function createSubtitleProcessingController(
|
||||
deps: SubtitleProcessingControllerDeps,
|
||||
): SubtitleProcessingController {
|
||||
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256;
|
||||
let latestText = '';
|
||||
let lastEmittedText = '';
|
||||
let processing = false;
|
||||
let staleDropCount = 0;
|
||||
let refreshRequested = false;
|
||||
const tokenizationCache = new Map<string, SubtitleData>();
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
|
||||
const getCachedTokenization = (text: string): SubtitleData | null => {
|
||||
const cached = tokenizationCache.get(text);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
tokenizationCache.delete(text);
|
||||
tokenizationCache.set(text, cached);
|
||||
return cached;
|
||||
};
|
||||
|
||||
const setCachedTokenization = (text: string, payload: SubtitleData): void => {
|
||||
tokenizationCache.set(text, payload);
|
||||
while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) {
|
||||
const firstKey = tokenizationCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
tokenizationCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processLatest = (): void => {
|
||||
if (processing) {
|
||||
return;
|
||||
@@ -44,9 +68,15 @@ export function createSubtitleProcessingController(
|
||||
|
||||
let output: SubtitleData = { text, tokens: null };
|
||||
try {
|
||||
const tokenized = await deps.tokenizeSubtitle(text);
|
||||
if (tokenized) {
|
||||
output = tokenized;
|
||||
const cachedTokenized = forceRefresh ? null : getCachedTokenization(text);
|
||||
if (cachedTokenized) {
|
||||
output = cachedTokenized;
|
||||
} else {
|
||||
const tokenized = await deps.tokenizeSubtitle(text);
|
||||
if (tokenized) {
|
||||
output = tokenized;
|
||||
}
|
||||
setCachedTokenization(text, output);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
|
||||
@@ -97,5 +127,8 @@ export function createSubtitleProcessingController(
|
||||
refreshRequested = true;
|
||||
processLatest();
|
||||
},
|
||||
invalidateTokenizationCache: () => {
|
||||
tokenizationCache.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,6 +55,19 @@ function makeDepsFromYomitanTokens(
|
||||
});
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve: ((value: T) => void) | null = null;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve: (value: T) => {
|
||||
resolve?.(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('tokenizeSubtitle assigns JLPT level to parsed Yomitan tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫です',
|
||||
@@ -169,6 +182,296 @@ test('tokenizeSubtitle applies frequency dictionary ranks', async () => {
|
||||
assert.equal(result.tokens?.[1]?.frequencyRank, 1200);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle loads frequency ranks from Yomitan installed dictionaries', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫',
|
||||
makeDeps({
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||
getYomitanParserWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
if (script.includes('getTermFrequencies')) {
|
||||
return [
|
||||
{
|
||||
term: '猫',
|
||||
reading: 'ねこ',
|
||||
dictionary: 'freq-dict',
|
||||
frequency: 77,
|
||||
displayValue: '77',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: '猫',
|
||||
reading: 'ねこ',
|
||||
headwords: [[{ term: '猫' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
}) as unknown as Electron.BrowserWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 77);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle starts Yomitan frequency lookup and MeCab enrichment in parallel', async () => {
|
||||
const frequencyDeferred = createDeferred<unknown[]>();
|
||||
const mecabDeferred = createDeferred<null>();
|
||||
let frequencyRequested = false;
|
||||
let mecabRequested = false;
|
||||
|
||||
const pendingResult = tokenizeSubtitle(
|
||||
'猫',
|
||||
makeDeps({
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||
getYomitanParserWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
if (script.includes('getTermFrequencies')) {
|
||||
frequencyRequested = true;
|
||||
return await frequencyDeferred.promise;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: '猫',
|
||||
reading: 'ねこ',
|
||||
headwords: [[{ term: '猫' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
}) as unknown as Electron.BrowserWindow,
|
||||
tokenizeWithMecab: async () => {
|
||||
mecabRequested = true;
|
||||
return await mecabDeferred.promise;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(frequencyRequested, true);
|
||||
assert.equal(mecabRequested, true);
|
||||
|
||||
frequencyDeferred.resolve([
|
||||
{
|
||||
term: '猫',
|
||||
reading: 'ねこ',
|
||||
dictionary: 'freq-dict',
|
||||
frequency: 77,
|
||||
displayValue: '77',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
]);
|
||||
mecabDeferred.resolve(null);
|
||||
|
||||
const result = await pendingResult;
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 77);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle queries headword frequencies with token reading for disambiguation', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'鍛えた',
|
||||
makeDeps({
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||
getYomitanParserWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
if (script.includes('getTermFrequencies')) {
|
||||
if (!script.includes('"term":"鍛える","reading":"きた"')) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
term: '鍛える',
|
||||
reading: 'きたえる',
|
||||
dictionary: 'freq-dict',
|
||||
frequency: 46961,
|
||||
displayValue: '2847,46961',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: '鍛えた',
|
||||
reading: 'きた',
|
||||
headwords: [[{ term: '鍛える' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
}) as unknown as Electron.BrowserWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.headword, '鍛える');
|
||||
assert.equal(result.tokens?.[0]?.reading, 'きた');
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 2847);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle avoids headword term-only fallback rank when reading-specific frequency exists', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'無人',
|
||||
makeDeps({
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||
getYomitanParserWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
if (script.includes('getTermFrequencies')) {
|
||||
if (!script.includes('"term":"無人","reading":"むじん"')) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
term: '無人',
|
||||
reading: null,
|
||||
dictionary: 'CC100',
|
||||
dictionaryPriority: 0,
|
||||
frequency: 157632,
|
||||
displayValue: null,
|
||||
displayValueParsed: false,
|
||||
},
|
||||
{
|
||||
term: '無人',
|
||||
reading: 'むじん',
|
||||
dictionary: 'CC100',
|
||||
dictionaryPriority: 0,
|
||||
frequency: 7141,
|
||||
displayValue: null,
|
||||
displayValueParsed: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: '無人',
|
||||
reading: 'むじん',
|
||||
headwords: [[{ term: '無人' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
}) as unknown as Electron.BrowserWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 7141);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle prefers Yomitan frequency from highest-priority dictionary', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫',
|
||||
makeDeps({
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||
getYomitanParserWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
if (script.includes('getTermFrequencies')) {
|
||||
return [
|
||||
{
|
||||
term: '猫',
|
||||
reading: 'ねこ',
|
||||
dictionary: 'low-priority',
|
||||
dictionaryPriority: 2,
|
||||
frequency: 5,
|
||||
displayValue: '5',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
{
|
||||
term: '猫',
|
||||
reading: 'ねこ',
|
||||
dictionary: 'high-priority',
|
||||
dictionaryPriority: 0,
|
||||
frequency: 100,
|
||||
displayValue: '100',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: '猫',
|
||||
reading: 'ねこ',
|
||||
headwords: [[{ term: '猫' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
}) as unknown as Electron.BrowserWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 100);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle uses only selected Yomitan headword for frequency lookup', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫です',
|
||||
@@ -1644,6 +1947,20 @@ test('tokenizeSubtitle checks known words by surface when configured', async ()
|
||||
assert.equal(result.tokens?.[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle uses frequency surface match mode when configured', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'鍛えた',
|
||||
makeDepsFromYomitanTokens([{ surface: '鍛えた', reading: 'きたえた', headword: '鍛える' }], {
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyDictionaryMatchMode: () => 'surface',
|
||||
getFrequencyRank: (text) => (text === '鍛えた' ? 2847 : null),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.text, '鍛えた');
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 2847);
|
||||
});
|
||||
|
||||
test('createTokenizerDepsRuntime checks MeCab availability before first tokenizeWithMecab call', async () => {
|
||||
let available = false;
|
||||
let checkCalls = 0;
|
||||
@@ -1696,3 +2013,291 @@ test('createTokenizerDepsRuntime checks MeCab availability before first tokenize
|
||||
assert.equal(first?.[0]?.surface, '仮面');
|
||||
assert.equal(second?.[0]?.surface, '仮面');
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle uses async MeCab enrichment override when provided', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫',
|
||||
makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], {
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: '猫',
|
||||
surface: '猫',
|
||||
reading: 'ネコ',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
enrichTokensWithMecab: async (tokens) =>
|
||||
tokens.map((token) => ({
|
||||
...token,
|
||||
pos1: 'override-pos',
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.pos1, 'override-pos');
|
||||
});
|
||||
|
||||
test('createTokenizerDepsRuntime exposes async MeCab enrichment helper', async () => {
|
||||
const deps = createTokenizerDepsRuntime({
|
||||
getYomitanExt: () => null,
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
getYomitanParserReadyPromise: () => null,
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
getYomitanParserInitPromise: () => null,
|
||||
setYomitanParserInitPromise: () => {},
|
||||
isKnownWord: () => false,
|
||||
getKnownWordMatchMode: () => 'headword',
|
||||
getJlptLevel: () => null,
|
||||
getMecabTokenizer: () => null,
|
||||
});
|
||||
|
||||
const enriched = await deps.enrichTokensWithMecab?.(
|
||||
[
|
||||
{
|
||||
headword: 'は',
|
||||
surface: 'は',
|
||||
reading: 'は',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
headword: 'は',
|
||||
surface: 'は',
|
||||
reading: 'ハ',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(enriched?.[0]?.pos1, '助詞');
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle skips all enrichment stages when disabled', async () => {
|
||||
let knownCalls = 0;
|
||||
let mecabCalls = 0;
|
||||
let jlptCalls = 0;
|
||||
let frequencyCalls = 0;
|
||||
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫',
|
||||
makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], {
|
||||
isKnownWord: () => {
|
||||
knownCalls += 1;
|
||||
return true;
|
||||
},
|
||||
getNPlusOneEnabled: () => false,
|
||||
getJlptEnabled: () => false,
|
||||
getFrequencyDictionaryEnabled: () => false,
|
||||
getJlptLevel: () => {
|
||||
jlptCalls += 1;
|
||||
return 'N5';
|
||||
},
|
||||
getFrequencyRank: () => {
|
||||
frequencyCalls += 1;
|
||||
return 10;
|
||||
},
|
||||
tokenizeWithMecab: async () => {
|
||||
mecabCalls += 1;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.isKnown, false);
|
||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
||||
assert.equal(knownCalls, 0);
|
||||
assert.equal(mecabCalls, 0);
|
||||
assert.equal(jlptCalls, 0);
|
||||
assert.equal(frequencyCalls, 0);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle keeps frequency enrichment while n+1 is disabled', async () => {
|
||||
let knownCalls = 0;
|
||||
let mecabCalls = 0;
|
||||
let frequencyCalls = 0;
|
||||
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫',
|
||||
makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], {
|
||||
isKnownWord: () => {
|
||||
knownCalls += 1;
|
||||
return true;
|
||||
},
|
||||
getNPlusOneEnabled: () => false,
|
||||
getJlptEnabled: () => false,
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: () => {
|
||||
frequencyCalls += 1;
|
||||
return 7;
|
||||
},
|
||||
tokenizeWithMecab: async () => {
|
||||
mecabCalls += 1;
|
||||
return [
|
||||
{
|
||||
headword: '猫',
|
||||
surface: '猫',
|
||||
reading: 'ネコ',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 7);
|
||||
assert.equal(result.tokens?.[0]?.isKnown, false);
|
||||
assert.equal(knownCalls, 0);
|
||||
assert.equal(mecabCalls, 1);
|
||||
assert.equal(frequencyCalls, 1);
|
||||
});
|
||||
|
||||
|
||||
test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and frequency annotations', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'になれば',
|
||||
makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], {
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: (text) => (text === 'なる' ? 11 : null),
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: 'なる',
|
||||
surface: 'になれば',
|
||||
reading: 'ニナレバ',
|
||||
startPos: 0,
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '非自立',
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
getMinSentenceWordsForNPlusOne: () => 1,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle keeps merged token when overlap contains at least one content pos1 tag', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'になれば',
|
||||
makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], {
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: (text) => (text === 'なる' ? 13 : null),
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: 'に',
|
||||
surface: 'に',
|
||||
reading: 'ニ',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '格助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'なる',
|
||||
surface: 'なれ',
|
||||
reading: 'ナレ',
|
||||
startPos: 1,
|
||||
endPos: 3,
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'ば',
|
||||
surface: 'ば',
|
||||
reading: 'バ',
|
||||
startPos: 3,
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '接続助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
getMinSentenceWordsForNPlusOne: () => 1,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.pos1, '助詞|動詞');
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 13);
|
||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle excludes default non-independent pos2 from N+1 when JLPT/frequency are disabled', async () => {
|
||||
let mecabCalls = 0;
|
||||
const result = await tokenizeSubtitle(
|
||||
'になれば',
|
||||
makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], {
|
||||
getJlptEnabled: () => false,
|
||||
getFrequencyDictionaryEnabled: () => false,
|
||||
getMinSentenceWordsForNPlusOne: () => 1,
|
||||
tokenizeWithMecab: async () => {
|
||||
mecabCalls += 1;
|
||||
return [
|
||||
{
|
||||
headword: 'なる',
|
||||
surface: 'になれば',
|
||||
reading: 'ニナレバ',
|
||||
startPos: 0,
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '非自立',
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(mecabCalls, 1);
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BrowserWindow, Extension } from 'electron';
|
||||
import { mergeTokens } from '../../token-merger';
|
||||
import { createLogger } from '../../logger';
|
||||
import {
|
||||
FrequencyDictionaryMatchMode,
|
||||
MergedToken,
|
||||
NPlusOneMatchMode,
|
||||
SubtitleData,
|
||||
@@ -9,13 +10,27 @@ import {
|
||||
FrequencyDictionaryLookup,
|
||||
JlptLevel,
|
||||
} from '../../types';
|
||||
import { annotateTokens } from './tokenizer/annotation-stage';
|
||||
import { enrichTokensWithMecabPos1 } from './tokenizer/parser-enrichment-stage';
|
||||
import {
|
||||
DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG,
|
||||
resolveAnnotationPos1ExclusionSet,
|
||||
} from '../../token-pos1-exclusions';
|
||||
import {
|
||||
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
|
||||
resolveAnnotationPos2ExclusionSet,
|
||||
} from '../../token-pos2-exclusions';
|
||||
import { selectYomitanParseTokens } from './tokenizer/parser-selection-stage';
|
||||
import { requestYomitanParseResults } from './tokenizer/yomitan-parser-runtime';
|
||||
import {
|
||||
requestYomitanParseResults,
|
||||
requestYomitanTermFrequencies,
|
||||
} from './tokenizer/yomitan-parser-runtime';
|
||||
|
||||
const logger = createLogger('main:tokenizer');
|
||||
|
||||
type MecabTokenEnrichmentFn = (
|
||||
tokens: MergedToken[],
|
||||
mecabTokens: MergedToken[] | null,
|
||||
) => Promise<MergedToken[]>;
|
||||
|
||||
export interface TokenizerServiceDeps {
|
||||
getYomitanExt: () => Extension | null;
|
||||
getYomitanParserWindow: () => BrowserWindow | null;
|
||||
@@ -27,12 +42,15 @@ export interface TokenizerServiceDeps {
|
||||
isKnownWord: (text: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
getMinSentenceWordsForNPlusOne?: () => number;
|
||||
getYomitanGroupDebugEnabled?: () => boolean;
|
||||
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
||||
enrichTokensWithMecab?: MecabTokenEnrichmentFn;
|
||||
}
|
||||
|
||||
interface MecabTokenizerLike {
|
||||
@@ -52,14 +70,100 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
isKnownWord: (text: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
getMinSentenceWordsForNPlusOne?: () => number;
|
||||
getYomitanGroupDebugEnabled?: () => boolean;
|
||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||
}
|
||||
|
||||
interface TokenizerAnnotationOptions {
|
||||
nPlusOneEnabled: boolean;
|
||||
jlptEnabled: boolean;
|
||||
frequencyEnabled: boolean;
|
||||
frequencyMatchMode: FrequencyDictionaryMatchMode;
|
||||
minSentenceWordsForNPlusOne: number | undefined;
|
||||
pos1Exclusions: ReadonlySet<string>;
|
||||
pos2Exclusions: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
let parserEnrichmentWorkerRuntimeModulePromise:
|
||||
| Promise<typeof import('./tokenizer/parser-enrichment-worker-runtime')>
|
||||
| null = null;
|
||||
let annotationStageModulePromise: Promise<typeof import('./tokenizer/annotation-stage')> | null = null;
|
||||
let parserEnrichmentFallbackModulePromise:
|
||||
| Promise<typeof import('./tokenizer/parser-enrichment-stage')>
|
||||
| null = null;
|
||||
const DEFAULT_ANNOTATION_POS1_EXCLUSIONS = resolveAnnotationPos1ExclusionSet(
|
||||
DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG,
|
||||
);
|
||||
const DEFAULT_ANNOTATION_POS2_EXCLUSIONS = resolveAnnotationPos2ExclusionSet(
|
||||
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
|
||||
);
|
||||
|
||||
function getKnownWordLookup(deps: TokenizerServiceDeps, options: TokenizerAnnotationOptions): (text: string) => boolean {
|
||||
if (!options.nPlusOneEnabled) {
|
||||
return () => false;
|
||||
}
|
||||
return deps.isKnownWord;
|
||||
}
|
||||
|
||||
function needsMecabPosEnrichment(options: TokenizerAnnotationOptions): boolean {
|
||||
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
|
||||
}
|
||||
|
||||
function hasAnyAnnotationEnabled(options: TokenizerAnnotationOptions): boolean {
|
||||
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
|
||||
}
|
||||
|
||||
async function enrichTokensWithMecabAsync(
|
||||
tokens: MergedToken[],
|
||||
mecabTokens: MergedToken[] | null,
|
||||
): Promise<MergedToken[]> {
|
||||
if (!parserEnrichmentWorkerRuntimeModulePromise) {
|
||||
parserEnrichmentWorkerRuntimeModulePromise = import('./tokenizer/parser-enrichment-worker-runtime');
|
||||
}
|
||||
|
||||
try {
|
||||
const runtime = await parserEnrichmentWorkerRuntimeModulePromise;
|
||||
return await runtime.enrichTokensWithMecabPos1Async(tokens, mecabTokens);
|
||||
} catch {
|
||||
if (!parserEnrichmentFallbackModulePromise) {
|
||||
parserEnrichmentFallbackModulePromise = import('./tokenizer/parser-enrichment-stage');
|
||||
}
|
||||
const fallback = await parserEnrichmentFallbackModulePromise;
|
||||
return fallback.enrichTokensWithMecabPos1(tokens, mecabTokens);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAnnotationStage(
|
||||
tokens: MergedToken[],
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
): Promise<MergedToken[]> {
|
||||
if (!hasAnyAnnotationEnabled(options)) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
if (!annotationStageModulePromise) {
|
||||
annotationStageModulePromise = import('./tokenizer/annotation-stage');
|
||||
}
|
||||
|
||||
const annotationStage = await annotationStageModulePromise;
|
||||
return annotationStage.annotateTokens(
|
||||
tokens,
|
||||
{
|
||||
isKnownWord: getKnownWordLookup(deps, options),
|
||||
knownWordMatchMode: deps.getKnownWordMatchMode(),
|
||||
getJlptLevel: deps.getJlptLevel,
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function createTokenizerDepsRuntime(
|
||||
options: TokenizerDepsRuntimeOptions,
|
||||
): TokenizerServiceDeps {
|
||||
@@ -76,8 +180,11 @@ export function createTokenizerDepsRuntime(
|
||||
isKnownWord: options.isKnownWord,
|
||||
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
||||
getJlptLevel: options.getJlptLevel,
|
||||
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
||||
getJlptEnabled: options.getJlptEnabled,
|
||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||
getFrequencyDictionaryMatchMode:
|
||||
options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
||||
getFrequencyRank: options.getFrequencyRank,
|
||||
getMinSentenceWordsForNPlusOne: options.getMinSentenceWordsForNPlusOne ?? (() => 3),
|
||||
getYomitanGroupDebugEnabled: options.getYomitanGroupDebugEnabled ?? (() => false),
|
||||
@@ -104,8 +211,11 @@ export function createTokenizerDepsRuntime(
|
||||
return null;
|
||||
}
|
||||
|
||||
return mergeTokens(rawTokens, options.isKnownWord, options.getKnownWordMatchMode());
|
||||
const isKnownWordLookup = options.getNPlusOneEnabled?.() === false ? () => false : options.isKnownWord;
|
||||
return mergeTokens(rawTokens, isKnownWordLookup, options.getKnownWordMatchMode());
|
||||
},
|
||||
enrichTokensWithMecab: async (tokens, mecabTokens) =>
|
||||
enrichTokensWithMecabAsync(tokens, mecabTokens),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,36 +238,181 @@ function logSelectedYomitanGroups(text: string, tokens: MergedToken[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
function getAnnotationOptions(deps: TokenizerServiceDeps): {
|
||||
jlptEnabled: boolean;
|
||||
frequencyEnabled: boolean;
|
||||
minSentenceWordsForNPlusOne: number | undefined;
|
||||
} {
|
||||
return {
|
||||
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
||||
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
||||
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
|
||||
};
|
||||
function normalizePositiveFrequencyRank(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function applyAnnotationStage(tokens: MergedToken[], deps: TokenizerServiceDeps): MergedToken[] {
|
||||
const options = getAnnotationOptions(deps);
|
||||
function normalizeFrequencyLookupText(rawText: string): string {
|
||||
return rawText.trim().toLowerCase();
|
||||
}
|
||||
|
||||
return annotateTokens(
|
||||
tokens,
|
||||
{
|
||||
isKnownWord: deps.isKnownWord,
|
||||
knownWordMatchMode: deps.getKnownWordMatchMode(),
|
||||
getJlptLevel: deps.getJlptLevel,
|
||||
getFrequencyRank: deps.getFrequencyRank,
|
||||
},
|
||||
options,
|
||||
);
|
||||
function resolveFrequencyLookupText(
|
||||
token: MergedToken,
|
||||
matchMode: FrequencyDictionaryMatchMode,
|
||||
): string {
|
||||
if (matchMode === 'surface') {
|
||||
if (token.surface && token.surface.length > 0) {
|
||||
return token.surface;
|
||||
}
|
||||
if (token.headword && token.headword.length > 0) {
|
||||
return token.headword;
|
||||
}
|
||||
return token.reading;
|
||||
}
|
||||
|
||||
if (token.headword && token.headword.length > 0) {
|
||||
return token.headword;
|
||||
}
|
||||
if (token.reading && token.reading.length > 0) {
|
||||
return token.reading;
|
||||
}
|
||||
return token.surface;
|
||||
}
|
||||
|
||||
function buildYomitanFrequencyTermReadingList(
|
||||
tokens: MergedToken[],
|
||||
matchMode: FrequencyDictionaryMatchMode,
|
||||
): Array<{ term: string; reading: string | null }> {
|
||||
return tokens
|
||||
.map((token) => {
|
||||
const term = resolveFrequencyLookupText(token, matchMode).trim();
|
||||
if (!term) {
|
||||
return null;
|
||||
}
|
||||
const readingRaw =
|
||||
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
|
||||
return { term, reading: readingRaw };
|
||||
})
|
||||
.filter((pair): pair is { term: string; reading: string | null } => pair !== null);
|
||||
}
|
||||
|
||||
function buildYomitanFrequencyRankMap(
|
||||
frequencies: ReadonlyArray<{ term: string; frequency: number; dictionaryPriority?: number }>,
|
||||
): Map<string, number> {
|
||||
const rankByTerm = new Map<string, { rank: number; dictionaryPriority: number }>();
|
||||
for (const frequency of frequencies) {
|
||||
const normalizedTerm = frequency.term.trim();
|
||||
const rank = normalizePositiveFrequencyRank(frequency.frequency);
|
||||
if (!normalizedTerm || rank === null) {
|
||||
continue;
|
||||
}
|
||||
const dictionaryPriority =
|
||||
typeof frequency.dictionaryPriority === 'number' && Number.isFinite(frequency.dictionaryPriority)
|
||||
? Math.max(0, Math.floor(frequency.dictionaryPriority))
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const current = rankByTerm.get(normalizedTerm);
|
||||
if (
|
||||
current === undefined ||
|
||||
dictionaryPriority < current.dictionaryPriority ||
|
||||
(dictionaryPriority === current.dictionaryPriority && rank < current.rank)
|
||||
) {
|
||||
rankByTerm.set(normalizedTerm, { rank, dictionaryPriority });
|
||||
}
|
||||
}
|
||||
|
||||
const collapsedRankByTerm = new Map<string, number>();
|
||||
for (const [term, entry] of rankByTerm.entries()) {
|
||||
collapsedRankByTerm.set(term, entry.rank);
|
||||
}
|
||||
|
||||
return collapsedRankByTerm;
|
||||
}
|
||||
|
||||
function getLocalFrequencyRank(
|
||||
lookupText: string,
|
||||
getFrequencyRank: FrequencyDictionaryLookup,
|
||||
cache: Map<string, number | null>,
|
||||
): number | null {
|
||||
const normalizedText = normalizeFrequencyLookupText(lookupText);
|
||||
if (!normalizedText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cache.has(normalizedText)) {
|
||||
return cache.get(normalizedText) ?? null;
|
||||
}
|
||||
|
||||
let rank: number | null;
|
||||
try {
|
||||
rank = getFrequencyRank(normalizedText);
|
||||
} catch {
|
||||
rank = null;
|
||||
}
|
||||
rank = normalizePositiveFrequencyRank(rank);
|
||||
cache.set(normalizedText, rank);
|
||||
return rank;
|
||||
}
|
||||
|
||||
function applyFrequencyRanks(
|
||||
tokens: MergedToken[],
|
||||
matchMode: FrequencyDictionaryMatchMode,
|
||||
yomitanRankByTerm: Map<string, number>,
|
||||
getFrequencyRank: FrequencyDictionaryLookup | undefined,
|
||||
): MergedToken[] {
|
||||
if (tokens.length === 0) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const localLookupCache = new Map<string, number | null>();
|
||||
return tokens.map((token) => {
|
||||
const existingRank = normalizePositiveFrequencyRank(token.frequencyRank);
|
||||
if (existingRank !== null) {
|
||||
return {
|
||||
...token,
|
||||
frequencyRank: existingRank,
|
||||
};
|
||||
}
|
||||
|
||||
const lookupText = resolveFrequencyLookupText(token, matchMode).trim();
|
||||
if (!lookupText) {
|
||||
return {
|
||||
...token,
|
||||
frequencyRank: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const yomitanRank = yomitanRankByTerm.get(lookupText);
|
||||
if (yomitanRank !== undefined) {
|
||||
return {
|
||||
...token,
|
||||
frequencyRank: yomitanRank,
|
||||
};
|
||||
}
|
||||
|
||||
if (!getFrequencyRank) {
|
||||
return {
|
||||
...token,
|
||||
frequencyRank: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const localRank = getLocalFrequencyRank(lookupText, getFrequencyRank, localLookupCache);
|
||||
return {
|
||||
...token,
|
||||
frequencyRank: localRank ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOptions {
|
||||
return {
|
||||
nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false,
|
||||
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
||||
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
||||
frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword',
|
||||
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
|
||||
pos1Exclusions: DEFAULT_ANNOTATION_POS1_EXCLUSIONS,
|
||||
pos2Exclusions: DEFAULT_ANNOTATION_POS2_EXCLUSIONS,
|
||||
};
|
||||
}
|
||||
|
||||
async function parseWithYomitanInternalParser(
|
||||
text: string,
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
): Promise<MergedToken[] | null> {
|
||||
const parseResults = await requestYomitanParseResults(text, deps, logger);
|
||||
if (!parseResults) {
|
||||
@@ -166,7 +421,7 @@ async function parseWithYomitanInternalParser(
|
||||
|
||||
const selectedTokens = selectYomitanParseTokens(
|
||||
parseResults,
|
||||
deps.isKnownWord,
|
||||
getKnownWordLookup(deps, options),
|
||||
deps.getKnownWordMatchMode(),
|
||||
);
|
||||
if (!selectedTokens || selectedTokens.length === 0) {
|
||||
@@ -177,19 +432,52 @@ async function parseWithYomitanInternalParser(
|
||||
logSelectedYomitanGroups(text, selectedTokens);
|
||||
}
|
||||
|
||||
try {
|
||||
const mecabTokens = await deps.tokenizeWithMecab(text);
|
||||
return enrichTokensWithMecabPos1(selectedTokens, mecabTokens);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.warn(
|
||||
'Failed to enrich Yomitan tokens with MeCab POS:',
|
||||
error.message,
|
||||
`tokenCount=${selectedTokens.length}`,
|
||||
`textLength=${text.length}`,
|
||||
const frequencyRankPromise: Promise<Map<string, number>> = options.frequencyEnabled
|
||||
? (async () => {
|
||||
const frequencyMatchMode = options.frequencyMatchMode;
|
||||
const termReadingList = buildYomitanFrequencyTermReadingList(
|
||||
selectedTokens,
|
||||
frequencyMatchMode,
|
||||
);
|
||||
const yomitanFrequencies = await requestYomitanTermFrequencies(termReadingList, deps, logger);
|
||||
return buildYomitanFrequencyRankMap(yomitanFrequencies);
|
||||
})()
|
||||
: Promise.resolve(new Map<string, number>());
|
||||
|
||||
const mecabEnrichmentPromise: Promise<MergedToken[]> = needsMecabPosEnrichment(options)
|
||||
? (async () => {
|
||||
try {
|
||||
const mecabTokens = await deps.tokenizeWithMecab(text);
|
||||
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
|
||||
return await enrichTokensWithMecab(selectedTokens, mecabTokens);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.warn(
|
||||
'Failed to enrich Yomitan tokens with MeCab POS:',
|
||||
error.message,
|
||||
`tokenCount=${selectedTokens.length}`,
|
||||
`textLength=${text.length}`,
|
||||
);
|
||||
return selectedTokens;
|
||||
}
|
||||
})()
|
||||
: Promise.resolve(selectedTokens);
|
||||
|
||||
const [yomitanRankByTerm, enrichedTokens] = await Promise.all([
|
||||
frequencyRankPromise,
|
||||
mecabEnrichmentPromise,
|
||||
]);
|
||||
|
||||
if (options.frequencyEnabled) {
|
||||
return applyFrequencyRanks(
|
||||
enrichedTokens,
|
||||
options.frequencyMatchMode,
|
||||
yomitanRankByTerm,
|
||||
deps.getFrequencyRank,
|
||||
);
|
||||
return selectedTokens;
|
||||
}
|
||||
|
||||
return enrichedTokens;
|
||||
}
|
||||
|
||||
export async function tokenizeSubtitle(
|
||||
@@ -207,12 +495,13 @@ export async function tokenizeSubtitle(
|
||||
}
|
||||
|
||||
const tokenizeText = displayText.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const annotationOptions = getAnnotationOptions(deps);
|
||||
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps);
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
|
||||
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||
return {
|
||||
text: displayText,
|
||||
tokens: applyAnnotationStage(yomitanTokens, deps),
|
||||
tokens: await applyAnnotationStage(yomitanTokens, deps, annotationOptions),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,15 +51,20 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
|
||||
});
|
||||
|
||||
test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 exclusions', () => {
|
||||
const lookupCalls: string[] = [];
|
||||
const tokens = [
|
||||
makeToken({ surface: 'は', headword: 'は', partOfSpeech: PartOfSpeech.particle }),
|
||||
makeToken({
|
||||
surface: 'は',
|
||||
headword: 'は',
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
frequencyRank: 3,
|
||||
}),
|
||||
makeToken({
|
||||
surface: 'です',
|
||||
headword: 'です',
|
||||
partOfSpeech: PartOfSpeech.bound_auxiliary,
|
||||
startPos: 1,
|
||||
endPos: 3,
|
||||
frequencyRank: 4,
|
||||
}),
|
||||
makeToken({
|
||||
surface: 'の',
|
||||
@@ -68,6 +73,7 @@ test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 ex
|
||||
pos1: '助詞',
|
||||
startPos: 3,
|
||||
endPos: 4,
|
||||
frequencyRank: 5,
|
||||
}),
|
||||
makeToken({
|
||||
surface: '猫',
|
||||
@@ -75,24 +81,36 @@ test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 ex
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
startPos: 4,
|
||||
endPos: 5,
|
||||
frequencyRank: 11,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
getFrequencyRank: (text) => {
|
||||
lookupCalls.push(text);
|
||||
return text === '猫' ? 11 : 999;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = annotateTokens(tokens, makeDeps());
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[1]?.frequencyRank, undefined);
|
||||
assert.equal(result[2]?.frequencyRank, undefined);
|
||||
assert.equal(result[3]?.frequencyRank, 11);
|
||||
assert.deepEqual(lookupCalls, ['猫']);
|
||||
});
|
||||
|
||||
test('annotateTokens preserves existing frequency rank when frequency is enabled', () => {
|
||||
const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: 42 })];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps());
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, 42);
|
||||
});
|
||||
|
||||
test('annotateTokens drops invalid frequency rank values', () => {
|
||||
const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: Number.NaN })];
|
||||
const result = annotateTokens(tokens, makeDeps());
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears frequency rank when frequency is disabled', () => {
|
||||
const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: 42 })];
|
||||
const result = annotateTokens(tokens, makeDeps(), { frequencyEnabled: false });
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens handles JLPT disabled and eligibility exclusion paths', () => {
|
||||
@@ -157,3 +175,206 @@ test('annotateTokens N+1 handoff marks expected target when threshold is satisfi
|
||||
assert.equal(result[1]?.isNPlusOneTarget, true);
|
||||
assert.equal(result[2]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens N+1 minimum sentence words counts only eligible word tokens', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '猫', headword: '猫', startPos: 0, endPos: 1 }),
|
||||
makeToken({
|
||||
surface: 'が',
|
||||
headword: 'が',
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
startPos: 1,
|
||||
endPos: 2,
|
||||
}),
|
||||
makeToken({
|
||||
surface: 'です',
|
||||
headword: 'です',
|
||||
partOfSpeech: PartOfSpeech.bound_auxiliary,
|
||||
pos1: '助動詞',
|
||||
startPos: 2,
|
||||
endPos: 4,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === 'が' || text === 'です',
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 3 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[1]?.isKnown, true);
|
||||
assert.equal(result[2]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens applies configured pos1 exclusions to both frequency and N+1', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '猫',
|
||||
headword: '猫',
|
||||
pos1: '名詞',
|
||||
frequencyRank: 21,
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
}),
|
||||
makeToken({
|
||||
surface: '走る',
|
||||
headword: '走る',
|
||||
pos1: '動詞',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
startPos: 1,
|
||||
endPos: 3,
|
||||
frequencyRank: 22,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '走る',
|
||||
}),
|
||||
{
|
||||
minSentenceWordsForNPlusOne: 1,
|
||||
pos1Exclusions: new Set(['名詞']),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[1]?.frequencyRank, 22);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[1]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens allows previously default-excluded pos1 when removed from effective set', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'は',
|
||||
headword: 'は',
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
pos1: '助詞',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
frequencyRank: 8,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), {
|
||||
minSentenceWordsForNPlusOne: 1,
|
||||
pos1Exclusions: new Set(),
|
||||
});
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, 8);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, true);
|
||||
});
|
||||
|
||||
test('annotateTokens excludes default non-independent pos2 from frequency and N+1', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'になれば',
|
||||
headword: 'なる',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '非自立',
|
||||
startPos: 0,
|
||||
endPos: 4,
|
||||
frequencyRank: 7,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), {
|
||||
minSentenceWordsForNPlusOne: 1,
|
||||
});
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens excludes likely kana SFX tokens from frequency when POS tags are missing', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'ぐわっ',
|
||||
reading: 'ぐわっ',
|
||||
headword: 'ぐわっ',
|
||||
pos1: '',
|
||||
pos2: '',
|
||||
frequencyRank: 12,
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), {
|
||||
minSentenceWordsForNPlusOne: 1,
|
||||
});
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens allows previously default-excluded pos2 when removed from effective set', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'になれば',
|
||||
headword: 'なる',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '非自立',
|
||||
startPos: 0,
|
||||
endPos: 4,
|
||||
frequencyRank: 9,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), {
|
||||
minSentenceWordsForNPlusOne: 1,
|
||||
pos2Exclusions: new Set(),
|
||||
});
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, 9);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, true);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps composite tokens when any component pos tag is content-bearing', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'になれば',
|
||||
headword: 'なる',
|
||||
pos1: '助詞|動詞',
|
||||
pos2: '格助詞|自立|接続助詞',
|
||||
startPos: 0,
|
||||
endPos: 4,
|
||||
frequencyRank: 5,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), {
|
||||
minSentenceWordsForNPlusOne: 1,
|
||||
});
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, 5);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, true);
|
||||
});
|
||||
|
||||
test('annotateTokens excludes composite tokens when all component pos tags are excluded', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'けど',
|
||||
headword: 'けど',
|
||||
pos1: '助詞|助詞',
|
||||
pos2: '接続助詞|終助詞',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
frequencyRank: 6,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), {
|
||||
minSentenceWordsForNPlusOne: 1,
|
||||
});
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { markNPlusOneTargets } from '../../../token-merger';
|
||||
import {
|
||||
FrequencyDictionaryLookup,
|
||||
JlptLevel,
|
||||
MergedToken,
|
||||
NPlusOneMatchMode,
|
||||
PartOfSpeech,
|
||||
} from '../../../types';
|
||||
DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG,
|
||||
resolveAnnotationPos1ExclusionSet,
|
||||
} from '../../../token-pos1-exclusions';
|
||||
import {
|
||||
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
|
||||
resolveAnnotationPos2ExclusionSet,
|
||||
} from '../../../token-pos2-exclusions';
|
||||
import { JlptLevel, MergedToken, NPlusOneMatchMode, PartOfSpeech } from '../../../types';
|
||||
import { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter';
|
||||
|
||||
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
const JLPT_LEVEL_LOOKUP_CACHE_LIMIT = 2048;
|
||||
const FREQUENCY_RANK_LOOKUP_CACHE_LIMIT = 2048;
|
||||
|
||||
const jlptLevelLookupCaches = new WeakMap<
|
||||
(text: string) => JlptLevel | null,
|
||||
Map<string, JlptLevel | null>
|
||||
>();
|
||||
const frequencyRankLookupCaches = new WeakMap<
|
||||
FrequencyDictionaryLookup,
|
||||
Map<string, number | null>
|
||||
>();
|
||||
|
||||
export interface AnnotationStageDeps {
|
||||
isKnownWord: (text: string) => boolean;
|
||||
knownWordMatchMode: NPlusOneMatchMode;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
}
|
||||
|
||||
export interface AnnotationStageOptions {
|
||||
nPlusOneEnabled?: boolean;
|
||||
jlptEnabled?: boolean;
|
||||
frequencyEnabled?: boolean;
|
||||
minSentenceWordsForNPlusOne?: number;
|
||||
pos1Exclusions?: ReadonlySet<string>;
|
||||
pos2Exclusions?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function resolveKnownWordText(
|
||||
@@ -59,106 +58,94 @@ function applyKnownWordMarking(
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFrequencyLookupText(rawText: string): string {
|
||||
return rawText.trim().toLowerCase();
|
||||
function normalizePos1Tag(pos1: string | undefined): string {
|
||||
return typeof pos1 === 'string' ? pos1.trim() : '';
|
||||
}
|
||||
|
||||
function getCachedFrequencyRank(
|
||||
lookupText: string,
|
||||
getFrequencyRank: FrequencyDictionaryLookup,
|
||||
): number | null {
|
||||
const normalizedText = normalizeFrequencyLookupText(lookupText);
|
||||
if (!normalizedText) {
|
||||
return null;
|
||||
function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<string>): boolean {
|
||||
if (!normalizedTag) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cache = frequencyRankLookupCaches.get(getFrequencyRank);
|
||||
if (!cache) {
|
||||
cache = new Map<string, number | null>();
|
||||
frequencyRankLookupCaches.set(getFrequencyRank, cache);
|
||||
const parts = normalizedTag
|
||||
.split('|')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
if (parts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cache.has(normalizedText)) {
|
||||
return cache.get(normalizedText) ?? null;
|
||||
}
|
||||
|
||||
let rank: number | null;
|
||||
try {
|
||||
rank = getFrequencyRank(normalizedText);
|
||||
} catch {
|
||||
rank = null;
|
||||
}
|
||||
if (rank !== null) {
|
||||
if (!Number.isFinite(rank) || rank <= 0) {
|
||||
rank = null;
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(normalizedText, rank);
|
||||
while (cache.size > FREQUENCY_RANK_LOOKUP_CACHE_LIMIT) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
return rank;
|
||||
// Composite tags like "助詞|名詞" stay eligible unless every component is excluded.
|
||||
return parts.every((part) => exclusions.has(part));
|
||||
}
|
||||
|
||||
function resolveFrequencyLookupText(token: MergedToken): string {
|
||||
if (token.headword && token.headword.length > 0) {
|
||||
return token.headword;
|
||||
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
|
||||
if (options.pos1Exclusions) {
|
||||
return options.pos1Exclusions;
|
||||
}
|
||||
if (token.reading && token.reading.length > 0) {
|
||||
return token.reading;
|
||||
}
|
||||
return token.surface;
|
||||
|
||||
return resolveAnnotationPos1ExclusionSet(DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG);
|
||||
}
|
||||
|
||||
function getFrequencyLookupTextCandidates(token: MergedToken): string[] {
|
||||
const lookupText = resolveFrequencyLookupText(token).trim();
|
||||
return lookupText ? [lookupText] : [];
|
||||
function resolvePos2Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
|
||||
if (options.pos2Exclusions) {
|
||||
return options.pos2Exclusions;
|
||||
}
|
||||
|
||||
return resolveAnnotationPos2ExclusionSet(DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG);
|
||||
}
|
||||
|
||||
function isFrequencyExcludedByPos(token: MergedToken): boolean {
|
||||
if (
|
||||
token.partOfSpeech === PartOfSpeech.particle ||
|
||||
token.partOfSpeech === PartOfSpeech.bound_auxiliary
|
||||
) {
|
||||
function normalizePos2Tag(pos2: string | undefined): string {
|
||||
return typeof pos2 === 'string' ? pos2.trim() : '';
|
||||
}
|
||||
|
||||
function isFrequencyExcludedByPos(
|
||||
token: MergedToken,
|
||||
pos1Exclusions: ReadonlySet<string>,
|
||||
pos2Exclusions: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const normalizedPos1 = normalizePos1Tag(token.pos1);
|
||||
const hasPos1 = normalizedPos1.length > 0;
|
||||
if (isExcludedByTagSet(normalizedPos1, pos1Exclusions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return token.pos1 === '助詞' || token.pos1 === '助動詞';
|
||||
const normalizedPos2 = normalizePos2Tag(token.pos2);
|
||||
const hasPos2 = normalizedPos2.length > 0;
|
||||
if (isExcludedByTagSet(normalizedPos2, pos2Exclusions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasPos1 || hasPos2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLikelyFrequencyNoiseToken(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
token.partOfSpeech === PartOfSpeech.particle ||
|
||||
token.partOfSpeech === PartOfSpeech.bound_auxiliary
|
||||
);
|
||||
}
|
||||
|
||||
function applyFrequencyMarking(
|
||||
tokens: MergedToken[],
|
||||
getFrequencyRank: FrequencyDictionaryLookup,
|
||||
pos1Exclusions: ReadonlySet<string>,
|
||||
pos2Exclusions: ReadonlySet<string>,
|
||||
): MergedToken[] {
|
||||
return tokens.map((token) => {
|
||||
if (isFrequencyExcludedByPos(token)) {
|
||||
if (isFrequencyExcludedByPos(token, pos1Exclusions, pos2Exclusions)) {
|
||||
return { ...token, frequencyRank: undefined };
|
||||
}
|
||||
|
||||
const lookupTexts = getFrequencyLookupTextCandidates(token);
|
||||
if (lookupTexts.length === 0) {
|
||||
return { ...token, frequencyRank: undefined };
|
||||
}
|
||||
|
||||
let bestRank: number | null = null;
|
||||
for (const lookupText of lookupTexts) {
|
||||
const rank = getCachedFrequencyRank(lookupText, getFrequencyRank);
|
||||
if (rank === null) {
|
||||
continue;
|
||||
}
|
||||
if (bestRank === null || rank < bestRank) {
|
||||
bestRank = rank;
|
||||
}
|
||||
if (typeof token.frequencyRank === 'number' && Number.isFinite(token.frequencyRank)) {
|
||||
const rank = Math.max(1, Math.floor(token.frequencyRank));
|
||||
return { ...token, frequencyRank: rank };
|
||||
}
|
||||
|
||||
return {
|
||||
...token,
|
||||
frequencyRank: bestRank ?? undefined,
|
||||
frequencyRank: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -282,6 +269,98 @@ function isRepeatedKanaSfx(text: string): boolean {
|
||||
return topCount >= Math.ceil(chars.length / 2);
|
||||
}
|
||||
|
||||
function isTrailingSmallTsuKanaSfx(text: string): boolean {
|
||||
const normalized = normalizeJlptTextForExclusion(text);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chars = [...normalized];
|
||||
if (chars.length < 2 || chars.length > 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!chars.every(isKanaChar)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return chars[chars.length - 1] === 'っ';
|
||||
}
|
||||
|
||||
function isReduplicatedKanaSfx(text: string): boolean {
|
||||
const normalized = normalizeJlptTextForExclusion(text);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chars = [...normalized];
|
||||
if (chars.length < 4 || chars.length % 2 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!chars.every(isKanaChar)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const half = chars.length / 2;
|
||||
return chars.slice(0, half).join('') === chars.slice(half).join('');
|
||||
}
|
||||
|
||||
function hasAdjacentKanaRepeat(text: string): boolean {
|
||||
const normalized = normalizeJlptTextForExclusion(text);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chars = [...normalized];
|
||||
if (!chars.every(isKanaChar)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 1; i < chars.length; i += 1) {
|
||||
if (chars[i] === chars[i - 1]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLikelyFrequencyNoiseToken(token: MergedToken): boolean {
|
||||
const candidates = [token.headword, token.surface].filter(
|
||||
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const trimmedCandidate = candidate.trim();
|
||||
if (!trimmedCandidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedCandidate = normalizeJlptTextForExclusion(trimmedCandidate);
|
||||
if (!normalizedCandidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldIgnoreJlptByTerm(trimmedCandidate) || shouldIgnoreJlptByTerm(normalizedCandidate)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasAdjacentKanaRepeat(trimmedCandidate) ||
|
||||
hasAdjacentKanaRepeat(normalizedCandidate) ||
|
||||
isReduplicatedKanaSfx(trimmedCandidate) ||
|
||||
isReduplicatedKanaSfx(normalizedCandidate) ||
|
||||
isTrailingSmallTsuKanaSfx(trimmedCandidate) ||
|
||||
isTrailingSmallTsuKanaSfx(normalizedCandidate)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isJlptEligibleToken(token: MergedToken): boolean {
|
||||
if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) {
|
||||
return false;
|
||||
@@ -340,20 +419,24 @@ export function annotateTokens(
|
||||
deps: AnnotationStageDeps,
|
||||
options: AnnotationStageOptions = {},
|
||||
): MergedToken[] {
|
||||
const knownMarkedTokens = applyKnownWordMarking(
|
||||
tokens,
|
||||
deps.isKnownWord,
|
||||
deps.knownWordMatchMode,
|
||||
);
|
||||
const pos1Exclusions = resolvePos1Exclusions(options);
|
||||
const pos2Exclusions = resolvePos2Exclusions(options);
|
||||
const nPlusOneEnabled = options.nPlusOneEnabled !== false;
|
||||
const knownMarkedTokens = nPlusOneEnabled
|
||||
? applyKnownWordMarking(tokens, deps.isKnownWord, deps.knownWordMatchMode)
|
||||
: tokens.map((token) => ({
|
||||
...token,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
}));
|
||||
|
||||
const frequencyEnabled = options.frequencyEnabled !== false;
|
||||
const frequencyMarkedTokens =
|
||||
frequencyEnabled && deps.getFrequencyRank
|
||||
? applyFrequencyMarking(knownMarkedTokens, deps.getFrequencyRank)
|
||||
: knownMarkedTokens.map((token) => ({
|
||||
...token,
|
||||
frequencyRank: undefined,
|
||||
}));
|
||||
const frequencyMarkedTokens = frequencyEnabled
|
||||
? applyFrequencyMarking(knownMarkedTokens, pos1Exclusions, pos2Exclusions)
|
||||
: knownMarkedTokens.map((token) => ({
|
||||
...token,
|
||||
frequencyRank: undefined,
|
||||
}));
|
||||
|
||||
const jlptEnabled = options.jlptEnabled !== false;
|
||||
const jlptMarkedTokens = jlptEnabled
|
||||
@@ -363,6 +446,14 @@ export function annotateTokens(
|
||||
jlptLevel: undefined,
|
||||
}));
|
||||
|
||||
if (!nPlusOneEnabled) {
|
||||
return jlptMarkedTokens.map((token) => ({
|
||||
...token,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const minSentenceWordsForNPlusOne = options.minSentenceWordsForNPlusOne;
|
||||
const sanitizedMinSentenceWordsForNPlusOne =
|
||||
minSentenceWordsForNPlusOne !== undefined &&
|
||||
@@ -371,5 +462,10 @@ export function annotateTokens(
|
||||
? minSentenceWordsForNPlusOne
|
||||
: 3;
|
||||
|
||||
return markNPlusOneTargets(jlptMarkedTokens, sanitizedMinSentenceWordsForNPlusOne);
|
||||
return markNPlusOneTargets(
|
||||
jlptMarkedTokens,
|
||||
sanitizedMinSentenceWordsForNPlusOne,
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ function makeToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
test('enrichTokensWithMecabPos1 picks pos1 by best overlap when no surface match exists', () => {
|
||||
const tokens = [makeToken({ surface: 'grouped', startPos: 2, endPos: 7 })];
|
||||
const mecabTokens = [
|
||||
makeToken({ surface: 'left', startPos: 0, endPos: 4, pos1: 'A' }),
|
||||
makeToken({ surface: 'right', startPos: 2, endPos: 6, pos1: 'B' }),
|
||||
makeToken({ surface: 'left', startPos: 0, endPos: 4, pos1: 'A', pos2: 'L2' }),
|
||||
makeToken({ surface: 'right', startPos: 2, endPos: 6, pos1: 'B', pos2: '非自立' }),
|
||||
];
|
||||
|
||||
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
|
||||
assert.equal(enriched[0]?.pos1, 'B');
|
||||
assert.equal(enriched[0]?.pos1, 'A|B');
|
||||
assert.equal(enriched[0]?.pos2, 'L2|非自立');
|
||||
});
|
||||
|
||||
test('enrichTokensWithMecabPos1 fills missing pos1 using surface-sequence fallback', () => {
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
import { MergedToken } from '../../../types';
|
||||
|
||||
function pickClosestMecabPos1(token: MergedToken, mecabTokens: MergedToken[]): string | undefined {
|
||||
if (mecabTokens.length === 0) {
|
||||
type MecabPosMetadata = {
|
||||
pos1: string;
|
||||
pos2?: string;
|
||||
pos3?: string;
|
||||
};
|
||||
|
||||
function joinUniqueTags(values: Array<string | undefined>): string | undefined {
|
||||
const unique: string[] = [];
|
||||
for (const value of values) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (!unique.includes(trimmed)) {
|
||||
unique.push(trimmed);
|
||||
}
|
||||
}
|
||||
if (unique.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (unique.length === 1) {
|
||||
return unique[0];
|
||||
}
|
||||
return unique.join('|');
|
||||
}
|
||||
|
||||
function pickClosestMecabPosMetadata(
|
||||
token: MergedToken,
|
||||
mecabTokens: MergedToken[],
|
||||
): MecabPosMetadata | null {
|
||||
if (mecabTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenStart = token.startPos ?? 0;
|
||||
const tokenEnd = token.endPos ?? tokenStart + token.surface.length;
|
||||
let bestSurfaceMatchPos1: string | undefined;
|
||||
let bestSurfaceMatchToken: MergedToken | null = null;
|
||||
let bestSurfaceMatchDistance = Number.MAX_SAFE_INTEGER;
|
||||
let bestSurfaceMatchEndDistance = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@@ -31,19 +63,24 @@ function pickClosestMecabPos1(token: MergedToken, mecabTokens: MergedToken[]): s
|
||||
) {
|
||||
bestSurfaceMatchDistance = startDistance;
|
||||
bestSurfaceMatchEndDistance = endDistance;
|
||||
bestSurfaceMatchPos1 = mecabToken.pos1;
|
||||
bestSurfaceMatchToken = mecabToken;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSurfaceMatchPos1) {
|
||||
return bestSurfaceMatchPos1;
|
||||
if (bestSurfaceMatchToken) {
|
||||
return {
|
||||
pos1: bestSurfaceMatchToken.pos1 as string,
|
||||
pos2: bestSurfaceMatchToken.pos2,
|
||||
pos3: bestSurfaceMatchToken.pos3,
|
||||
};
|
||||
}
|
||||
|
||||
let bestPos1: string | undefined;
|
||||
let bestToken: MergedToken | null = null;
|
||||
let bestOverlap = 0;
|
||||
let bestSpan = 0;
|
||||
let bestStartDistance = Number.MAX_SAFE_INTEGER;
|
||||
let bestStart = Number.MAX_SAFE_INTEGER;
|
||||
const overlappingTokens: MergedToken[] = [];
|
||||
|
||||
for (const mecabToken of mecabTokens) {
|
||||
if (!mecabToken.pos1) {
|
||||
@@ -58,6 +95,7 @@ function pickClosestMecabPos1(token: MergedToken, mecabTokens: MergedToken[]): s
|
||||
if (overlap === 0) {
|
||||
continue;
|
||||
}
|
||||
overlappingTokens.push(mecabToken);
|
||||
|
||||
const span = mecabEnd - mecabStart;
|
||||
if (
|
||||
@@ -71,11 +109,23 @@ function pickClosestMecabPos1(token: MergedToken, mecabTokens: MergedToken[]): s
|
||||
bestSpan = span;
|
||||
bestStartDistance = Math.abs(mecabStart - tokenStart);
|
||||
bestStart = mecabStart;
|
||||
bestPos1 = mecabToken.pos1;
|
||||
bestToken = mecabToken;
|
||||
}
|
||||
}
|
||||
|
||||
return bestOverlap > 0 ? bestPos1 : undefined;
|
||||
if (bestOverlap === 0 || !bestToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlapPos1 = joinUniqueTags(overlappingTokens.map((token) => token.pos1));
|
||||
const overlapPos2 = joinUniqueTags(overlappingTokens.map((token) => token.pos2));
|
||||
const overlapPos3 = joinUniqueTags(overlappingTokens.map((token) => token.pos3));
|
||||
|
||||
return {
|
||||
pos1: overlapPos1 ?? (bestToken.pos1 as string),
|
||||
pos2: overlapPos2 ?? bestToken.pos2,
|
||||
pos3: overlapPos3 ?? bestToken.pos3,
|
||||
};
|
||||
}
|
||||
|
||||
function fillMissingPos1BySurfaceSequence(
|
||||
@@ -101,7 +151,7 @@ function fillMissingPos1BySurfaceSequence(
|
||||
return token;
|
||||
}
|
||||
|
||||
let best: { pos1: string; index: number } | null = null;
|
||||
let best: { token: MergedToken; index: number } | null = null;
|
||||
for (const candidate of indexedMecabTokens) {
|
||||
if (candidate.token.surface !== surface) {
|
||||
continue;
|
||||
@@ -109,7 +159,7 @@ function fillMissingPos1BySurfaceSequence(
|
||||
if (candidate.index < cursor) {
|
||||
continue;
|
||||
}
|
||||
best = { pos1: candidate.token.pos1 as string, index: candidate.index };
|
||||
best = { token: candidate.token, index: candidate.index };
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -118,7 +168,7 @@ function fillMissingPos1BySurfaceSequence(
|
||||
if (candidate.token.surface !== surface) {
|
||||
continue;
|
||||
}
|
||||
best = { pos1: candidate.token.pos1 as string, index: candidate.index };
|
||||
best = { token: candidate.token, index: candidate.index };
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -130,7 +180,9 @@ function fillMissingPos1BySurfaceSequence(
|
||||
cursor = best.index + 1;
|
||||
return {
|
||||
...token,
|
||||
pos1: best.pos1,
|
||||
pos1: best.token.pos1,
|
||||
pos2: best.token.pos2,
|
||||
pos3: best.token.pos3,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -152,14 +204,16 @@ export function enrichTokensWithMecabPos1(
|
||||
return token;
|
||||
}
|
||||
|
||||
const pos1 = pickClosestMecabPos1(token, mecabTokens);
|
||||
if (!pos1) {
|
||||
const metadata = pickClosestMecabPosMetadata(token, mecabTokens);
|
||||
if (!metadata) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return {
|
||||
...token,
|
||||
pos1,
|
||||
pos1: metadata.pos1,
|
||||
pos2: metadata.pos2,
|
||||
pos3: metadata.pos3,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
149
src/core/services/tokenizer/parser-enrichment-worker-runtime.ts
Normal file
149
src/core/services/tokenizer/parser-enrichment-worker-runtime.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { MergedToken } from '../../../types';
|
||||
import { createLogger } from '../../../logger';
|
||||
import { enrichTokensWithMecabPos1 } from './parser-enrichment-stage';
|
||||
|
||||
const logger = createLogger('main:tokenizer');
|
||||
const DISABLE_WORKER_ENV = 'SUBMINER_DISABLE_MECAB_ENRICHMENT_WORKER';
|
||||
|
||||
interface WorkerRequest {
|
||||
id: number;
|
||||
tokens: MergedToken[];
|
||||
mecabTokens: MergedToken[] | null;
|
||||
}
|
||||
|
||||
interface WorkerResponse {
|
||||
id?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: MergedToken[]) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
class ParserEnrichmentWorkerRuntime {
|
||||
private worker: import('node:worker_threads').Worker | null = null;
|
||||
private nextRequestId = 1;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private initAttempted = false;
|
||||
|
||||
async enrichTokens(
|
||||
tokens: MergedToken[],
|
||||
mecabTokens: MergedToken[] | null,
|
||||
): Promise<MergedToken[]> {
|
||||
const worker = await this.getWorker();
|
||||
if (!worker) {
|
||||
return enrichTokensWithMecabPos1(tokens, mecabTokens);
|
||||
}
|
||||
|
||||
return new Promise<MergedToken[]>((resolve, reject) => {
|
||||
const id = this.nextRequestId++;
|
||||
this.pending.set(id, { resolve, reject });
|
||||
const request: WorkerRequest = { id, tokens, mecabTokens };
|
||||
worker.postMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
private async getWorker(): Promise<import('node:worker_threads').Worker | null> {
|
||||
if (process.env[DISABLE_WORKER_ENV] === '1') {
|
||||
return null;
|
||||
}
|
||||
if (this.worker) {
|
||||
return this.worker;
|
||||
}
|
||||
if (this.initAttempted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.initAttempted = true;
|
||||
|
||||
let workerThreads: typeof import('node:worker_threads');
|
||||
try {
|
||||
workerThreads = await import('node:worker_threads');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let workerPath = '';
|
||||
try {
|
||||
workerPath = require.resolve('./parser-enrichment-worker-thread.js');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const worker = new workerThreads.Worker(workerPath);
|
||||
worker.on('message', (message: WorkerResponse) => this.handleWorkerMessage(message));
|
||||
worker.on('error', (error: Error) => this.handleWorkerFailure(error));
|
||||
worker.on('exit', (code: number) => {
|
||||
if (code !== 0) {
|
||||
this.handleWorkerFailure(new Error(`parser enrichment worker exited with code ${code}`));
|
||||
} else {
|
||||
this.worker = null;
|
||||
}
|
||||
});
|
||||
this.worker = worker;
|
||||
return worker;
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to start parser enrichment worker: ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleWorkerMessage(message: WorkerResponse): void {
|
||||
if (typeof message.id !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = this.pending.get(message.id);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(message.id);
|
||||
|
||||
if (typeof message.error === 'string' && message.error.length > 0) {
|
||||
request.reject(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(message.result)) {
|
||||
request.reject(new Error('Parser enrichment worker returned invalid payload'));
|
||||
return;
|
||||
}
|
||||
|
||||
request.resolve(message.result as MergedToken[]);
|
||||
}
|
||||
|
||||
private handleWorkerFailure(error: Error): void {
|
||||
logger.debug(
|
||||
`Parser enrichment worker unavailable, falling back to main thread: ${error.message}`,
|
||||
);
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
|
||||
if (this.worker) {
|
||||
this.worker.removeAllListeners();
|
||||
this.worker = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let runtime: ParserEnrichmentWorkerRuntime | null = null;
|
||||
|
||||
export async function enrichTokensWithMecabPos1Async(
|
||||
tokens: MergedToken[],
|
||||
mecabTokens: MergedToken[] | null,
|
||||
): Promise<MergedToken[]> {
|
||||
if (!runtime) {
|
||||
runtime = new ParserEnrichmentWorkerRuntime();
|
||||
}
|
||||
|
||||
try {
|
||||
return await runtime.enrichTokens(tokens, mecabTokens);
|
||||
} catch {
|
||||
return enrichTokensWithMecabPos1(tokens, mecabTokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
import type { MergedToken } from '../../../types';
|
||||
import { enrichTokensWithMecabPos1 } from './parser-enrichment-stage';
|
||||
|
||||
interface WorkerRequest {
|
||||
id: number;
|
||||
tokens: MergedToken[];
|
||||
mecabTokens: MergedToken[] | null;
|
||||
}
|
||||
|
||||
if (!parentPort) {
|
||||
throw new Error('parser-enrichment worker missing parent port');
|
||||
}
|
||||
|
||||
const port = parentPort;
|
||||
|
||||
port.on('message', (message: WorkerRequest) => {
|
||||
try {
|
||||
const result = enrichTokensWithMecabPos1(message.tokens, message.mecabTokens);
|
||||
port.postMessage({ id: message.id, result });
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
port.postMessage({ id: message.id, error: messageText });
|
||||
}
|
||||
});
|
||||
248
src/core/services/tokenizer/yomitan-parser-runtime.test.ts
Normal file
248
src/core/services/tokenizer/yomitan-parser-runtime.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
requestYomitanTermFrequencies,
|
||||
syncYomitanDefaultAnkiServer,
|
||||
} from './yomitan-parser-runtime';
|
||||
|
||||
function createDeps(executeJavaScript: (script: string) => Promise<unknown>) {
|
||||
const parserWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => await executeJavaScript(script),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getYomitanExt: () => ({ id: 'ext-id' }) as never,
|
||||
getYomitanParserWindow: () => parserWindow as never,
|
||||
setYomitanParserWindow: () => undefined,
|
||||
getYomitanParserReadyPromise: () => null,
|
||||
setYomitanParserReadyPromise: () => undefined,
|
||||
getYomitanParserInitPromise: () => null,
|
||||
setYomitanParserInitPromise: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => {
|
||||
let scriptValue = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
scriptValue = script;
|
||||
return { updated: true };
|
||||
});
|
||||
|
||||
const infoLogs: string[] = [];
|
||||
const updated = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, {
|
||||
error: () => undefined,
|
||||
info: (message) => infoLogs.push(message),
|
||||
});
|
||||
|
||||
assert.equal(updated, true);
|
||||
assert.match(scriptValue, /optionsGetFull/);
|
||||
assert.match(scriptValue, /setAllSettings/);
|
||||
assert.equal(infoLogs.length, 1);
|
||||
});
|
||||
|
||||
test('syncYomitanDefaultAnkiServer returns false when script reports no change', async () => {
|
||||
const deps = createDeps(async () => ({ updated: false }));
|
||||
|
||||
const updated = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, {
|
||||
error: () => undefined,
|
||||
info: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(updated, false);
|
||||
});
|
||||
|
||||
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
||||
const deps = createDeps(async () => {
|
||||
throw new Error('execute failed');
|
||||
});
|
||||
|
||||
const errorLogs: string[] = [];
|
||||
const updated = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, {
|
||||
error: (message) => errorLogs.push(message),
|
||||
info: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(updated, false);
|
||||
assert.equal(errorLogs.length, 1);
|
||||
});
|
||||
|
||||
test('syncYomitanDefaultAnkiServer no-ops for empty target url', async () => {
|
||||
let executeCount = 0;
|
||||
const deps = createDeps(async () => {
|
||||
executeCount += 1;
|
||||
return { updated: true };
|
||||
});
|
||||
|
||||
const updated = await syncYomitanDefaultAnkiServer(' ', deps, {
|
||||
error: () => undefined,
|
||||
info: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(updated, false);
|
||||
assert.equal(executeCount, 0);
|
||||
});
|
||||
|
||||
test('requestYomitanTermFrequencies returns normalized frequency entries', async () => {
|
||||
let scriptValue = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
scriptValue = script;
|
||||
return [
|
||||
{
|
||||
term: '猫',
|
||||
reading: 'ねこ',
|
||||
dictionary: 'freq-dict',
|
||||
dictionaryPriority: 0,
|
||||
frequency: 77,
|
||||
displayValue: '77',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
{
|
||||
term: '鍛える',
|
||||
reading: 'きたえる',
|
||||
dictionary: 'freq-dict',
|
||||
dictionaryPriority: 1,
|
||||
frequency: 46961,
|
||||
displayValue: '2847,46961',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
{
|
||||
term: 'invalid',
|
||||
dictionary: 'freq-dict',
|
||||
frequency: 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const result = await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(result.length, 2);
|
||||
assert.equal(result[0]?.term, '猫');
|
||||
assert.equal(result[0]?.frequency, 77);
|
||||
assert.equal(result[0]?.dictionaryPriority, 0);
|
||||
assert.equal(result[1]?.term, '鍛える');
|
||||
assert.equal(result[1]?.frequency, 2847);
|
||||
assert.match(scriptValue, /getTermFrequencies/);
|
||||
assert.match(scriptValue, /optionsGetFull/);
|
||||
});
|
||||
|
||||
test('requestYomitanTermFrequencies prefers primary rank from displayValue array pair', async () => {
|
||||
const deps = createDeps(async () => [
|
||||
{
|
||||
term: '無人',
|
||||
reading: 'むじん',
|
||||
dictionary: 'freq-dict',
|
||||
dictionaryPriority: 0,
|
||||
frequency: 157632,
|
||||
displayValue: [7141, 157632],
|
||||
displayValueParsed: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await requestYomitanTermFrequencies([{ term: '無人', reading: 'むじん' }], deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0]?.term, '無人');
|
||||
assert.equal(result[0]?.frequency, 7141);
|
||||
});
|
||||
|
||||
test('requestYomitanTermFrequencies caches profile metadata between calls', async () => {
|
||||
const scripts: string[] = [];
|
||||
const deps = createDeps(async (script) => {
|
||||
scripts.push(script);
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (script.includes('"term":"犬"')) {
|
||||
return [
|
||||
{
|
||||
term: '犬',
|
||||
reading: 'いぬ',
|
||||
dictionary: 'freq-dict',
|
||||
frequency: 12,
|
||||
displayValue: '12',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
term: '猫',
|
||||
reading: 'ねこ',
|
||||
dictionary: 'freq-dict',
|
||||
frequency: 77,
|
||||
displayValue: '77',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
await requestYomitanTermFrequencies([{ term: '犬', reading: 'いぬ' }], deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
const optionsCalls = scripts.filter((script) => script.includes('optionsGetFull')).length;
|
||||
assert.equal(optionsCalls, 1);
|
||||
});
|
||||
|
||||
test('requestYomitanTermFrequencies caches repeated term+reading lookups', async () => {
|
||||
const scripts: string[] = [];
|
||||
const deps = createDeps(async (script) => {
|
||||
scripts.push(script);
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
term: '猫',
|
||||
reading: 'ねこ',
|
||||
dictionary: 'freq-dict',
|
||||
frequency: 77,
|
||||
displayValue: '77',
|
||||
displayValueParsed: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
const frequencyCalls = scripts.filter((script) => script.includes('getTermFrequencies')).length;
|
||||
assert.equal(frequencyCalls, 1);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { BrowserWindow, Extension } from 'electron';
|
||||
|
||||
interface LoggerLike {
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
info?: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
interface YomitanParserRuntimeDeps {
|
||||
@@ -14,6 +15,395 @@ interface YomitanParserRuntimeDeps {
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
}
|
||||
|
||||
export interface YomitanTermFrequency {
|
||||
term: string;
|
||||
reading: string | null;
|
||||
dictionary: string;
|
||||
dictionaryPriority: number;
|
||||
frequency: number;
|
||||
displayValue: string | null;
|
||||
displayValueParsed: boolean;
|
||||
}
|
||||
|
||||
export interface YomitanTermReadingPair {
|
||||
term: string;
|
||||
reading: string | null;
|
||||
}
|
||||
|
||||
interface YomitanProfileMetadata {
|
||||
profileIndex: number;
|
||||
scanLength: number;
|
||||
dictionaries: string[];
|
||||
dictionaryPriorityByName: Record<string, number>;
|
||||
}
|
||||
|
||||
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
|
||||
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
|
||||
const yomitanFrequencyCacheByWindow = new WeakMap<BrowserWindow, Map<string, YomitanTermFrequency[]>>();
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object');
|
||||
}
|
||||
|
||||
function makeTermReadingCacheKey(term: string, reading: string | null): string {
|
||||
return `${term}\u0000${reading ?? ''}`;
|
||||
}
|
||||
|
||||
function getWindowFrequencyCache(window: BrowserWindow): Map<string, YomitanTermFrequency[]> {
|
||||
let cache = yomitanFrequencyCacheByWindow.get(window);
|
||||
if (!cache) {
|
||||
cache = new Map<string, YomitanTermFrequency[]>();
|
||||
yomitanFrequencyCacheByWindow.set(window, cache);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function clearWindowCaches(window: BrowserWindow): void {
|
||||
yomitanProfileMetadataByWindow.delete(window);
|
||||
yomitanFrequencyCacheByWindow.delete(window);
|
||||
}
|
||||
export function clearYomitanParserCachesForWindow(window: BrowserWindow): void {
|
||||
clearWindowCaches(window);
|
||||
}
|
||||
|
||||
function asPositiveInteger(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function parsePositiveFrequencyString(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericPrefix = trimmed.match(/^\d[\d,]*/)?.[0];
|
||||
if (!numericPrefix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunks = numericPrefix.split(',');
|
||||
const normalizedNumber =
|
||||
chunks.length <= 1
|
||||
? chunks[0] ?? ''
|
||||
: chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk))
|
||||
? chunks.join('')
|
||||
: (chunks[0] ?? '');
|
||||
const parsed = Number.parseInt(normalizedNumber, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveFrequencyValue(value: unknown): number | null {
|
||||
const numeric = asPositiveInteger(value);
|
||||
if (numeric !== null) {
|
||||
return numeric;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return parsePositiveFrequencyString(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const parsed = parsePositiveFrequencyValue(item);
|
||||
if (parsed !== null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const term = typeof value.term === 'string' ? value.term.trim() : '';
|
||||
const dictionary = typeof value.dictionary === 'string' ? value.dictionary.trim() : '';
|
||||
const rawFrequency = parsePositiveFrequencyValue(value.frequency);
|
||||
const displayValueRaw = value.displayValue;
|
||||
const parsedDisplayFrequency =
|
||||
displayValueRaw !== null && displayValueRaw !== undefined
|
||||
? parsePositiveFrequencyValue(displayValueRaw)
|
||||
: null;
|
||||
const frequency = parsedDisplayFrequency ?? rawFrequency;
|
||||
if (!term || !dictionary || frequency === null) {
|
||||
return null;
|
||||
}
|
||||
const dictionaryPriorityRaw = (value as { dictionaryPriority?: unknown }).dictionaryPriority;
|
||||
const dictionaryPriority =
|
||||
typeof dictionaryPriorityRaw === 'number' && Number.isFinite(dictionaryPriorityRaw)
|
||||
? Math.max(0, Math.floor(dictionaryPriorityRaw))
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const reading =
|
||||
value.reading === null
|
||||
? null
|
||||
: typeof value.reading === 'string'
|
||||
? value.reading
|
||||
: null;
|
||||
const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null;
|
||||
const displayValueParsed = value.displayValueParsed === true;
|
||||
|
||||
return {
|
||||
term,
|
||||
reading,
|
||||
dictionary,
|
||||
dictionaryPriority,
|
||||
frequency,
|
||||
displayValue,
|
||||
displayValueParsed,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTermReadingList(termReadingList: YomitanTermReadingPair[]): YomitanTermReadingPair[] {
|
||||
const normalized: YomitanTermReadingPair[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const pair of termReadingList) {
|
||||
const term = typeof pair.term === 'string' ? pair.term.trim() : '';
|
||||
if (!term) {
|
||||
continue;
|
||||
}
|
||||
const reading =
|
||||
typeof pair.reading === 'string' && pair.reading.trim().length > 0 ? pair.reading.trim() : null;
|
||||
const key = `${term}\u0000${reading ?? ''}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
normalized.push({ term, reading });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toYomitanProfileMetadata(value: unknown): YomitanProfileMetadata | null {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileIndexRaw = value.profileIndex ?? value.profileCurrent;
|
||||
const profileIndex =
|
||||
typeof profileIndexRaw === 'number' && Number.isFinite(profileIndexRaw)
|
||||
? Math.max(0, Math.floor(profileIndexRaw))
|
||||
: 0;
|
||||
const scanLengthRaw =
|
||||
value.scanLength ??
|
||||
(Array.isArray(value.profiles) && isObject(value.profiles[profileIndex])
|
||||
? (value.profiles[profileIndex] as { options?: { scanning?: { length?: unknown } } }).options
|
||||
?.scanning?.length
|
||||
: undefined);
|
||||
const scanLength =
|
||||
typeof scanLengthRaw === 'number' && Number.isFinite(scanLengthRaw)
|
||||
? Math.max(1, Math.floor(scanLengthRaw))
|
||||
: DEFAULT_YOMITAN_SCAN_LENGTH;
|
||||
const dictionariesRaw =
|
||||
value.dictionaries ??
|
||||
(Array.isArray(value.profiles) && isObject(value.profiles[profileIndex])
|
||||
? (value.profiles[profileIndex] as { options?: { dictionaries?: unknown[] } }).options
|
||||
?.dictionaries
|
||||
: undefined);
|
||||
const dictionaries = Array.isArray(dictionariesRaw)
|
||||
? dictionariesRaw
|
||||
.map((entry, index) => {
|
||||
if (typeof entry === 'string') {
|
||||
return { name: entry.trim(), priority: index };
|
||||
}
|
||||
if (!isObject(entry) || entry.enabled === false || typeof entry.name !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalizedName = entry.name.trim();
|
||||
if (!normalizedName) {
|
||||
return null;
|
||||
}
|
||||
const priorityRaw = (entry as { id?: unknown }).id;
|
||||
const priority =
|
||||
typeof priorityRaw === 'number' && Number.isFinite(priorityRaw)
|
||||
? Math.max(0, Math.floor(priorityRaw))
|
||||
: index;
|
||||
return { name: normalizedName, priority };
|
||||
})
|
||||
.filter((entry): entry is { name: string; priority: number } => entry !== null)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((entry) => entry.name)
|
||||
.filter((entry) => entry.length > 0)
|
||||
: [];
|
||||
const dictionaryPriorityByNameRaw = value.dictionaryPriorityByName;
|
||||
const dictionaryPriorityByName: Record<string, number> = {};
|
||||
if (isObject(dictionaryPriorityByNameRaw)) {
|
||||
for (const [name, priorityRaw] of Object.entries(dictionaryPriorityByNameRaw)) {
|
||||
if (typeof priorityRaw !== 'number' || !Number.isFinite(priorityRaw)) {
|
||||
continue;
|
||||
}
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
dictionaryPriorityByName[normalizedName] = Math.max(0, Math.floor(priorityRaw));
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < dictionaries.length; index += 1) {
|
||||
const dictionary = dictionaries[index];
|
||||
if (!dictionary) {
|
||||
continue;
|
||||
}
|
||||
if (dictionaryPriorityByName[dictionary] === undefined) {
|
||||
dictionaryPriorityByName[dictionary] = index;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
profileIndex,
|
||||
scanLength,
|
||||
dictionaries,
|
||||
dictionaryPriorityByName,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrequencyEntriesWithPriority(
|
||||
rawResult: unknown[],
|
||||
dictionaryPriorityByName: Record<string, number>,
|
||||
): YomitanTermFrequency[] {
|
||||
const normalized: YomitanTermFrequency[] = [];
|
||||
for (const entry of rawResult) {
|
||||
const frequency = toYomitanTermFrequency(entry);
|
||||
if (!frequency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dictionaryPriority = dictionaryPriorityByName[frequency.dictionary];
|
||||
normalized.push({
|
||||
...frequency,
|
||||
dictionaryPriority:
|
||||
dictionaryPriority !== undefined ? dictionaryPriority : frequency.dictionaryPriority,
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function groupFrequencyEntriesByPair(
|
||||
entries: YomitanTermFrequency[],
|
||||
): Map<string, YomitanTermFrequency[]> {
|
||||
const grouped = new Map<string, YomitanTermFrequency[]>();
|
||||
for (const entry of entries) {
|
||||
const reading =
|
||||
typeof entry.reading === 'string' && entry.reading.trim().length > 0 ? entry.reading.trim() : null;
|
||||
const key = makeTermReadingCacheKey(entry.term.trim(), reading);
|
||||
const existing = grouped.get(key);
|
||||
if (existing) {
|
||||
existing.push(entry);
|
||||
continue;
|
||||
}
|
||||
grouped.set(key, [entry]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function groupFrequencyEntriesByTerm(
|
||||
entries: YomitanTermFrequency[],
|
||||
): Map<string, YomitanTermFrequency[]> {
|
||||
const grouped = new Map<string, YomitanTermFrequency[]>();
|
||||
for (const entry of entries) {
|
||||
const term = entry.term.trim();
|
||||
if (!term) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = grouped.get(term);
|
||||
if (existing) {
|
||||
existing.push(entry);
|
||||
continue;
|
||||
}
|
||||
grouped.set(term, [entry]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async function requestYomitanProfileMetadata(
|
||||
parserWindow: BrowserWindow,
|
||||
logger: LoggerLike,
|
||||
): Promise<YomitanProfileMetadata | null> {
|
||||
const cached = yomitanProfileMetadataByWindow.get(parserWindow);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const script = `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
|
||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||
const profileIndex =
|
||||
typeof optionsFull.profileCurrent === "number" && Number.isFinite(optionsFull.profileCurrent)
|
||||
? Math.max(0, Math.floor(optionsFull.profileCurrent))
|
||||
: 0;
|
||||
const scanLengthRaw = optionsFull.profiles?.[profileIndex]?.options?.scanning?.length;
|
||||
const scanLength =
|
||||
typeof scanLengthRaw === "number" && Number.isFinite(scanLengthRaw)
|
||||
? Math.max(1, Math.floor(scanLengthRaw))
|
||||
: ${DEFAULT_YOMITAN_SCAN_LENGTH};
|
||||
const dictionariesRaw = optionsFull.profiles?.[profileIndex]?.options?.dictionaries ?? [];
|
||||
const dictionaryEntries = Array.isArray(dictionariesRaw)
|
||||
? dictionariesRaw
|
||||
.filter((entry) => entry && typeof entry === "object" && entry.enabled === true && typeof entry.name === "string")
|
||||
.map((entry, index) => ({
|
||||
name: entry.name,
|
||||
id: typeof entry.id === "number" && Number.isFinite(entry.id) ? Math.max(0, Math.floor(entry.id)) : index
|
||||
}))
|
||||
.sort((a, b) => a.id - b.id)
|
||||
: [];
|
||||
const dictionaries = dictionaryEntries.map((entry) => entry.name);
|
||||
const dictionaryPriorityByName = dictionaryEntries.reduce((acc, entry, index) => {
|
||||
acc[entry.name] = index;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { profileIndex, scanLength, dictionaries, dictionaryPriorityByName };
|
||||
})();
|
||||
`;
|
||||
|
||||
try {
|
||||
const rawMetadata = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
const metadata = toYomitanProfileMetadata(rawMetadata);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
yomitanProfileMetadataByWindow.set(parserWindow, metadata);
|
||||
return metadata;
|
||||
} catch (err) {
|
||||
logger.error('Yomitan parser metadata request failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureYomitanParserWindow(
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
@@ -58,6 +448,7 @@ async function ensureYomitanParserWindow(
|
||||
);
|
||||
|
||||
parserWindow.on('closed', () => {
|
||||
clearWindowCaches(parserWindow);
|
||||
if (deps.getYomitanParserWindow() === parserWindow) {
|
||||
deps.setYomitanParserWindow(null);
|
||||
deps.setYomitanParserReadyPromise(null);
|
||||
@@ -77,6 +468,7 @@ async function ensureYomitanParserWindow(
|
||||
if (!parserWindow.isDestroyed()) {
|
||||
parserWindow.destroy();
|
||||
}
|
||||
clearWindowCaches(parserWindow);
|
||||
if (deps.getYomitanParserWindow() === parserWindow) {
|
||||
deps.setYomitanParserWindow(null);
|
||||
deps.setYomitanParserReadyPromise(null);
|
||||
@@ -108,7 +500,40 @@ export async function requestYomitanParseResults(
|
||||
return null;
|
||||
}
|
||||
|
||||
const script = `
|
||||
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
|
||||
const script =
|
||||
metadata !== null
|
||||
? `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
|
||||
return await invoke("parseText", {
|
||||
text: ${JSON.stringify(text)},
|
||||
optionsContext: { index: ${metadata.profileIndex} },
|
||||
scanLength: ${metadata.scanLength},
|
||||
useInternalParser: true,
|
||||
useMecabParser: true
|
||||
});
|
||||
})();
|
||||
`
|
||||
: `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -132,7 +557,7 @@ export async function requestYomitanParseResults(
|
||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||
const profileIndex = optionsFull.profileCurrent;
|
||||
const scanLength =
|
||||
optionsFull.profiles?.[profileIndex]?.options?.scanning?.length ?? 40;
|
||||
optionsFull.profiles?.[profileIndex]?.options?.scanning?.length ?? ${DEFAULT_YOMITAN_SCAN_LENGTH};
|
||||
|
||||
return await invoke("parseText", {
|
||||
text: ${JSON.stringify(text)},
|
||||
@@ -152,3 +577,278 @@ export async function requestYomitanParseResults(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestYomitanTermFrequencies(
|
||||
termReadingList: YomitanTermReadingPair[],
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<YomitanTermFrequency[]> {
|
||||
const normalizedTermReadingList = normalizeTermReadingList(termReadingList);
|
||||
const yomitanExt = deps.getYomitanExt();
|
||||
if (normalizedTermReadingList.length === 0 || !yomitanExt) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
|
||||
const frequencyCache = getWindowFrequencyCache(parserWindow);
|
||||
const missingTermReadingList: YomitanTermReadingPair[] = [];
|
||||
|
||||
const buildCachedResult = (): YomitanTermFrequency[] => {
|
||||
const result: YomitanTermFrequency[] = [];
|
||||
for (const pair of normalizedTermReadingList) {
|
||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||
const cached = frequencyCache.get(key);
|
||||
if (cached && cached.length > 0) {
|
||||
result.push(...cached);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
for (const pair of normalizedTermReadingList) {
|
||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||
if (!frequencyCache.has(key)) {
|
||||
missingTermReadingList.push(pair);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingTermReadingList.length === 0) {
|
||||
return buildCachedResult();
|
||||
}
|
||||
|
||||
if (metadata && metadata.dictionaries.length > 0) {
|
||||
const script = `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
|
||||
return await invoke("getTermFrequencies", {
|
||||
termReadingList: ${JSON.stringify(missingTermReadingList)},
|
||||
dictionaries: ${JSON.stringify(metadata.dictionaries)}
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
try {
|
||||
const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
const fetchedEntries = Array.isArray(rawResult)
|
||||
? normalizeFrequencyEntriesWithPriority(rawResult, metadata.dictionaryPriorityByName)
|
||||
: [];
|
||||
const groupedByPair = groupFrequencyEntriesByPair(fetchedEntries);
|
||||
const groupedByTerm = groupFrequencyEntriesByTerm(fetchedEntries);
|
||||
const missingTerms = new Set(missingTermReadingList.map((pair) => pair.term));
|
||||
|
||||
for (const pair of missingTermReadingList) {
|
||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||
const exactEntries = groupedByPair.get(key);
|
||||
const termEntries = groupedByTerm.get(pair.term) ?? [];
|
||||
frequencyCache.set(key, exactEntries ?? termEntries);
|
||||
}
|
||||
|
||||
const cachedResult = buildCachedResult();
|
||||
const unmatchedEntries = fetchedEntries.filter((entry) => !missingTerms.has(entry.term.trim()));
|
||||
return [...cachedResult, ...unmatchedEntries];
|
||||
} catch (err) {
|
||||
logger.error('Yomitan term frequency request failed:', (err as Error).message);
|
||||
}
|
||||
|
||||
return buildCachedResult();
|
||||
}
|
||||
|
||||
const script = `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
|
||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||
const profileIndex = optionsFull.profileCurrent;
|
||||
const dictionariesRaw = optionsFull.profiles?.[profileIndex]?.options?.dictionaries ?? [];
|
||||
const dictionaryEntries = Array.isArray(dictionariesRaw)
|
||||
? dictionariesRaw
|
||||
.filter((entry) => entry && typeof entry === "object" && entry.enabled === true && typeof entry.name === "string")
|
||||
.map((entry, index) => ({
|
||||
name: entry.name,
|
||||
id: typeof entry.id === "number" && Number.isFinite(entry.id) ? Math.floor(entry.id) : index
|
||||
}))
|
||||
.sort((a, b) => a.id - b.id)
|
||||
: [];
|
||||
const dictionaries = dictionaryEntries.map((entry) => entry.name);
|
||||
const dictionaryPriorityByName = dictionaryEntries.reduce((acc, entry, index) => {
|
||||
acc[entry.name] = index;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (dictionaries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rawFrequencies = await invoke("getTermFrequencies", {
|
||||
termReadingList: ${JSON.stringify(missingTermReadingList)},
|
||||
dictionaries
|
||||
});
|
||||
|
||||
if (!Array.isArray(rawFrequencies)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawFrequencies
|
||||
.filter((entry) => entry && typeof entry === "object")
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
dictionaryPriority:
|
||||
typeof entry.dictionary === "string" && dictionaryPriorityByName[entry.dictionary] !== undefined
|
||||
? dictionaryPriorityByName[entry.dictionary]
|
||||
: Number.MAX_SAFE_INTEGER
|
||||
}));
|
||||
})();
|
||||
`;
|
||||
|
||||
try {
|
||||
const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
const fetchedEntries = Array.isArray(rawResult)
|
||||
? rawResult
|
||||
.map((entry) => toYomitanTermFrequency(entry))
|
||||
.filter((entry): entry is YomitanTermFrequency => entry !== null)
|
||||
: [];
|
||||
const groupedByPair = groupFrequencyEntriesByPair(fetchedEntries);
|
||||
const groupedByTerm = groupFrequencyEntriesByTerm(fetchedEntries);
|
||||
const missingTerms = new Set(missingTermReadingList.map((pair) => pair.term));
|
||||
for (const pair of missingTermReadingList) {
|
||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||
const exactEntries = groupedByPair.get(key);
|
||||
const termEntries = groupedByTerm.get(pair.term) ?? [];
|
||||
frequencyCache.set(key, exactEntries ?? termEntries);
|
||||
}
|
||||
const cachedResult = buildCachedResult();
|
||||
const unmatchedEntries = fetchedEntries.filter((entry) => !missingTerms.has(entry.term.trim()));
|
||||
return [...cachedResult, ...unmatchedEntries];
|
||||
} catch (err) {
|
||||
logger.error('Yomitan term frequency request failed:', (err as Error).message);
|
||||
return buildCachedResult();
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncYomitanDefaultAnkiServer(
|
||||
serverUrl: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<boolean> {
|
||||
const normalizedTargetServer = serverUrl.trim();
|
||||
if (!normalizedTargetServer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const script = `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
|
||||
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
if (profiles.length === 0) {
|
||||
return { updated: false, reason: "no-profiles" };
|
||||
}
|
||||
|
||||
const defaultProfile = profiles[0];
|
||||
if (!defaultProfile || typeof defaultProfile !== "object") {
|
||||
return { updated: false, reason: "invalid-default-profile" };
|
||||
}
|
||||
|
||||
defaultProfile.options = defaultProfile.options && typeof defaultProfile.options === "object"
|
||||
? defaultProfile.options
|
||||
: {};
|
||||
defaultProfile.options.anki = defaultProfile.options.anki && typeof defaultProfile.options.anki === "object"
|
||||
? defaultProfile.options.anki
|
||||
: {};
|
||||
|
||||
const currentServerRaw = defaultProfile.options.anki.server;
|
||||
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
|
||||
const canReplaceDefault =
|
||||
currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
||||
if (!canReplaceDefault || currentServer === targetServer) {
|
||||
return { updated: false, reason: "no-change", currentServer, targetServer };
|
||||
}
|
||||
|
||||
defaultProfile.options.anki.server = targetServer;
|
||||
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
||||
return { updated: true, currentServer, targetServer };
|
||||
})();
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
const updated =
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
(result as { updated?: unknown }).updated === true;
|
||||
if (updated) {
|
||||
logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
logger.error('Failed to sync Yomitan default profile Anki server:', (err as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface OpenYomitanSettingsWindowOptions {
|
||||
yomitanExt: Extension | null;
|
||||
getExistingWindow: () => BrowserWindow | null;
|
||||
setWindow: (window: BrowserWindow | null) => void;
|
||||
onWindowClosed?: () => void;
|
||||
}
|
||||
|
||||
export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOptions): void {
|
||||
@@ -81,6 +82,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
}, 500);
|
||||
|
||||
settingsWindow.on('closed', () => {
|
||||
options.onWindowClosed?.();
|
||||
options.setWindow(null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Config } from '../../types';
|
||||
|
||||
export interface ConfiguredShortcuts {
|
||||
toggleVisibleOverlayGlobal: string | null | undefined;
|
||||
toggleInvisibleOverlayGlobal: string | null | undefined;
|
||||
copySubtitle: string | null | undefined;
|
||||
copySubtitleMultiple: string | null | undefined;
|
||||
updateLastCardFromClipboard: string | null | undefined;
|
||||
@@ -33,10 +32,6 @@ export function resolveConfiguredShortcuts(
|
||||
config.shortcuts?.toggleVisibleOverlayGlobal ??
|
||||
defaultConfig.shortcuts?.toggleVisibleOverlayGlobal,
|
||||
),
|
||||
toggleInvisibleOverlayGlobal: normalizeShortcut(
|
||||
config.shortcuts?.toggleInvisibleOverlayGlobal ??
|
||||
defaultConfig.shortcuts?.toggleInvisibleOverlayGlobal,
|
||||
),
|
||||
copySubtitle: normalizeShortcut(
|
||||
config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user