From a954f62f55332255e83120e4185e740f42ba2287 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 18 Mar 2026 23:49:27 -0700 Subject: [PATCH] Decouple stats daemon and preserve final mine OSD status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run `subminer stats -b` as a dedicated daemon process, independent from the overlay app - Stop Anki progress spinner before showing final `✓`/`x` mine result so it is not overwritten - Keep grammar/noise subtitle tokens hoverable while stripping annotation metadata --- ...m-being-overwritten-by-progress-spinner.md | 64 ++++ changes/2026-03-18-mine-osd-spinner-result.md | 4 + changes/2026-03-18-stats-daemon-decoupling.md | 5 + .../2026-03-18-subtitle-noise-filtering.md | 3 +- docs-site/immersion-tracking.md | 9 +- docs-site/public/screenshots/annotations.png | Bin 39326 -> 43310 bytes docs-site/subtitle-annotations.md | 6 + launcher/commands/command-modules.test.ts | 27 +- launcher/commands/stats-command.ts | 33 +- launcher/config/cli-parser-builder.ts | 10 + launcher/main.test.ts | 2 +- launcher/mpv.ts | 24 +- src/anki-integration.ts | 29 +- src/anki-integration/card-creation.ts | 11 +- src/anki-integration/ui-feedback.test.ts | 67 ++++ src/anki-integration/ui-feedback.ts | 27 ++ src/core/services/subtitle-ws.test.ts | 24 ++ src/core/services/tokenizer.test.ts | 351 +++++++++++++++++- src/core/services/tokenizer.ts | 10 +- .../tokenizer/annotation-stage.test.ts | 171 ++++++++- .../services/tokenizer/annotation-stage.ts | 101 ++++- src/main-entry-runtime.test.ts | 20 + src/main-entry-runtime.ts | 8 + src/main-entry.ts | 7 + src/renderer/subtitle-render.test.ts | 12 +- src/stats-daemon-control.test.ts | 158 ++++++++ src/stats-daemon-control.ts | 102 +++++ src/stats-daemon-entry.ts | 135 +++++++ src/stats-daemon-runner.ts | 225 +++++++++++ src/stats-word-helper-client.test.ts | 57 +++ src/stats-word-helper-client.ts | 62 ++++ src/stats-word-helper.ts | 193 ++++++++++ 32 files changed, 1879 insertions(+), 78 deletions(-) create mode 100644 backlog/tasks/task-195 - Keep-final-card-mine-OSD-result-from-being-overwritten-by-progress-spinner.md create mode 100644 changes/2026-03-18-mine-osd-spinner-result.md create mode 100644 changes/2026-03-18-stats-daemon-decoupling.md create mode 100644 src/anki-integration/ui-feedback.test.ts create mode 100644 src/stats-daemon-control.test.ts create mode 100644 src/stats-daemon-control.ts create mode 100644 src/stats-daemon-entry.ts create mode 100644 src/stats-daemon-runner.ts create mode 100644 src/stats-word-helper-client.test.ts create mode 100644 src/stats-word-helper-client.ts create mode 100644 src/stats-word-helper.ts diff --git a/backlog/tasks/task-195 - Keep-final-card-mine-OSD-result-from-being-overwritten-by-progress-spinner.md b/backlog/tasks/task-195 - Keep-final-card-mine-OSD-result-from-being-overwritten-by-progress-spinner.md new file mode 100644 index 0000000..e7f6fcf --- /dev/null +++ b/backlog/tasks/task-195 - Keep-final-card-mine-OSD-result-from-being-overwritten-by-progress-spinner.md @@ -0,0 +1,64 @@ +--- +id: TASK-195 +title: Keep final card-mine OSD result from being overwritten by progress spinner +status: Done +assignee: + - Codex +created_date: '2026-03-18 19:40' +updated_date: '2026-03-18 19:49' +labels: + - anki + - ui + - bug +milestone: m-1 +dependencies: [] +references: + - src/anki-integration/ui-feedback.ts + - src/anki-integration.ts + - src/anki-integration/card-creation.ts +priority: medium +ordinal: 105610 +--- + +## Description + + + +When a card mine finishes, the mpv OSD currently tries to show the final status text but the in-flight Anki progress spinner can immediately overwrite it on the next tick. Stop the spinner first, then show a single-line final result with a success/failure marker and the mined-word notification. + + + +## Acceptance Criteria + + + +- [x] #1 Successful mine/update OSD results render after the spinner is stopped and do not get overwritten by a later spinner tick. +- [x] #2 Failure results that replace the spinner show an `x` marker and stay visible on the same OSD line. +- [x] #3 Regression coverage locks the spinner teardown/result-notification ordering. + + +## Implementation Plan + + + +1. Add a focused failing regression test around the Anki UI-feedback spinner/result helper. +2. Add a helper that stops progress before emitting the final OSD result line with `✓`/`x`. +3. Route mine/update result notifications through that helper, then run targeted verification. + + +## Outcome + + + +Added a dedicated Anki UI-feedback result helper that force-clears the in-flight spinner state before emitting the final OSD result line. Successful card-update notifications now render as `✓ Updated card: ...`, and sentence-card creation failures now render as `x Sentence card failed: ...` without a later spinner tick reclaiming the line. + +Verification: + +- `bun test src/anki-integration/ui-feedback.test.ts` +- `bun test src/anki-integration/ui-feedback.test.ts src/anki-integration/note-update-workflow.test.ts src/anki-integration.test.ts src/core/services/mining.test.ts src/main/runtime/mining-actions.test.ts` +- `bun x prettier --check src/anki-integration/ui-feedback.ts src/anki-integration/ui-feedback.test.ts src/anki-integration.ts src/anki-integration/card-creation.ts "backlog/tasks/task-195 - Keep-final-card-mine-OSD-result-from-being-overwritten-by-progress-spinner.md" changes/2026-03-18-mine-osd-spinner-result.md` +- `bun run changelog:lint` +- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/anki-integration/ui-feedback.ts src/anki-integration/ui-feedback.test.ts src/anki-integration.ts src/anki-integration/card-creation.ts changes/2026-03-18-mine-osd-spinner-result.md` +- Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260318-194614-uZMrAx/` + + diff --git a/changes/2026-03-18-mine-osd-spinner-result.md b/changes/2026-03-18-mine-osd-spinner-result.md new file mode 100644 index 0000000..3a8e501 --- /dev/null +++ b/changes/2026-03-18-mine-osd-spinner-result.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed card-mine OSD feedback so the final mine result stops the Anki spinner first, then shows a single-line `✓`/`x` status without being overwritten by a later spinner tick. diff --git a/changes/2026-03-18-stats-daemon-decoupling.md b/changes/2026-03-18-stats-daemon-decoupling.md new file mode 100644 index 0000000..5a826d8 --- /dev/null +++ b/changes/2026-03-18-stats-daemon-decoupling.md @@ -0,0 +1,5 @@ +type: fixed +area: stats + +- `subminer stats -b` now runs as a standalone background stats daemon instead of reusing the main SubMiner app process, so the overlay app can still be launched separately for normal video watching. +- Dashboard word mining still works against the background daemon by using a short-lived hidden helper for the Yomitan add-note flow. diff --git a/changes/2026-03-18-subtitle-noise-filtering.md b/changes/2026-03-18-subtitle-noise-filtering.md index eb302c7..ccb65cf 100644 --- a/changes/2026-03-18-subtitle-noise-filtering.md +++ b/changes/2026-03-18-subtitle-noise-filtering.md @@ -1,4 +1,5 @@ type: changed area: overlay -- Excluded interjections and sound-effect tokens from subtitle annotation styling so they no longer inherit misleading lexical highlight treatment while still remaining visible and non-interactive in the subtitle line. +- Excluded interjections and sound-effect tokens from subtitle annotation styling so they no longer inherit misleading lexical highlight treatment while still remaining visible and hoverable as plain subtitle tokens. +- Expanded subtitle annotation noise filtering to also strip annotation metadata from standalone grammar-only helper tokens such as particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, and merged trailing quote-particle forms like `...って` while keeping them tokenized for hover lookup. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index f7519ec..076c396 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -26,7 +26,7 @@ The same immersion data powers the stats dashboard. - In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`). - Launcher command: run `subminer stats` to start the local stats server on demand and open the dashboard in your browser. -- Background server: run `subminer stats -b` to start or reuse a dedicated background stats server without keeping the launcher attached, and `subminer stats -s` to stop that background server. +- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon. - Maintenance command: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand. - Browser page: open `http://127.0.0.1:5175` directly if the local stats server is already running. @@ -80,8 +80,9 @@ Stats server config lives under `stats`: - `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. - `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running. - `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`. -- `subminer stats -b` starts or reuses the dedicated background stats server and exits after startup acknowledgement. -- `subminer stats -s` stops the dedicated background stats server without closing any browser tabs. +- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement. +- The background stats daemon is separate from the normal SubMiner overlay app, so you can leave it running and still launch SubMiner later to watch or mine from video. +- `subminer stats -s` stops the dedicated background stats daemon without closing any browser tabs. - `subminer stats` fails with an error when `immersionTracking.enabled` is `false`. - `subminer stats cleanup` defaults to vocabulary cleanup, repairs stale `headword`, `reading`, and `part_of_speech` values, attempts best-effort MeCab backfill for legacy rows, and removes rows that still fail vocab filtering. @@ -89,7 +90,7 @@ Stats server config lives under `stats`: The Vocabulary tab's word detail panel shows example lines from your viewing history. Each example line with a valid source file offers three mining buttons: -- **Mine Word** — performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via the hidden search page, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded. +- **Mine Word** — performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded. - **Mine Sentence** — creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio, image, and translation from the secondary subtitle if available. - **Mine Audio** — creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip. diff --git a/docs-site/public/screenshots/annotations.png b/docs-site/public/screenshots/annotations.png index 37918a59bccc96db0961e422bae83e8bf3930cba..b289395a071eed6a41bf4f624b239c06bf6d27f6 100644 GIT binary patch literal 43310 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

s?i0qHO%4(sab+`gfmof}=9om53K ziAOuRxjVT#!O$2>ixwAuAI5DJkLU%WzE%rjv5fD4|WHl)n zme}>sT6%x`sr*PJ6Nq`t*BGnU{E>2VDvFiJ@G-Zm>})Y&iCgoURb}O*T3=^*6i)*9 z5GF_MPPaLn<_>yp(dP1TG_h>^MUde!4eaRYzifl%QRoeB$k3&eL0tfn=~I;>Ha=Z@ z8#rS|+(%8)xd4F!Zgd6FV`LN*Li~`!Gl0yj0HEmH!JG)4$AVJwBgUfkz90Q~&B2Ss zteu?vwb$`WPg*2uzh7xjvreFf)C59naqTHHFO-F*dfGPmUO5<9XONR^?PB4D4Yx}?P zQ!x;nZxf|0E0d-tz`HqPN(2iTE48Lo-%7&7gnEjM_z1ys?!AHgAx9%EeFvHw;0kns zf;ug-lMUE|K&h<6iXdF;vWR|wKLPeUy-($RPIE4BBU5J06{5wCna`Nl#8*`vC;k{# zpZ1^nYx{qY{IN;RJxO1r&X7*q=}6J1Byqq{&VC%(Okx(ST?$k)bFGRTbug4^(xqWZq^u z#&|JD`Do+n?NqVI$fWv-bBBMqBxE~v5W<4%!7aaHPJVQ$dC9u2Pn2(E?)t|fWTZL* zG%*zBokeSU3sg-8zoRv~qMSzNIbFJBUZke`>R9{SW72Z(q04$1y-)EDd^VWO{I49u zP(OKOi!C~~YG1dN(=ue_i-A5AJ%i~)xF^ovIH6FU=c=DCJqL8m>_3WpGGpm8&}VKjMhSlkE*8h@udA*CT zqx%X%@N45ZvzFRPrH~@s5)fGFa$B1Ng#mn`1bX8AoI&AWBQi_s7}{-g)F|Fn>!>ie z#6w$5v_%$NC6vF;;6mbCk?}7OnC5s>xJOv3WBep3jk5*O@)Yd!?UW-;l<}�j{W2 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 { +test('stats background command launches attached daemon control command with response path', async () => { const context = createContext(); context.args.stats = true; (context.args as typeof context.args & { statsBackground?: boolean }).statsBackground = true; @@ -168,11 +169,9 @@ test('stats background command launches detached app command with response path' const handled = await runStatsCommand(context, { createTempDir: () => '/tmp/subminer-stats-test', joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => { - throw new Error('attached path should not run for stats -b'); - }, - launchAppCommandDetached: (_appPath, appArgs) => { + runAppCommandAttached: async (_appPath, appArgs) => { forwarded.push(appArgs); + return 0; }, waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), removeDir: () => {}, @@ -181,10 +180,9 @@ test('stats background command launches detached app command with response path' assert.equal(handled, true); assert.deepEqual(forwarded, [ [ - '--stats', + '--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json', - '--stats-background', ], ]); }); @@ -215,7 +213,12 @@ test('stats command returns after startup response even if app process stays run const final = await statsCommand; assert.equal(final, true); assert.deepEqual(forwarded, [ - ['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json'], + [ + '--stats-daemon-start', + '--stats-response-path', + '/tmp/subminer-stats-test/response.json', + '--stats-daemon-open-browser', + ], ]); }); @@ -268,7 +271,11 @@ test('stats stop command forwards stop flag to the app', async () => { assert.equal(handled, true); assert.deepEqual(forwarded, [ - ['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--stats-stop'], + [ + '--stats-daemon-stop', + '--stats-response-path', + '/tmp/subminer-stats-test/response.json', + ], ]); }); diff --git a/launcher/commands/stats-command.ts b/launcher/commands/stats-command.ts index e21e8f6..95ff576 100644 --- a/launcher/commands/stats-command.ts +++ b/launcher/commands/stats-command.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { launchAppCommandDetached, runAppCommandAttached } from '../mpv.js'; +import { runAppCommandAttached } from '../mpv.js'; import { sleep } from '../util.js'; import type { LauncherCommandContext } from './context.js'; @@ -20,12 +20,6 @@ type StatsCommandDeps = { logLevel: LauncherCommandContext['args']['logLevel'], label: string, ) => Promise; - launchAppCommandDetached: ( - appPath: string, - appArgs: string[], - logLevel: LauncherCommandContext['args']['logLevel'], - label: string, - ) => void; waitForStatsResponse: (responsePath: string) => Promise; removeDir: (targetPath: string) => void; }; @@ -37,8 +31,6 @@ const defaultDeps: StatsCommandDeps = { joinPath: (...parts) => path.join(...parts), runAppCommandAttached: (appPath, appArgs, logLevel, label) => runAppCommandAttached(appPath, appArgs, logLevel, label), - launchAppCommandDetached: (appPath, appArgs, logLevel, label) => - launchAppCommandDetached(appPath, appArgs, logLevel, label), waitForStatsResponse: async (responsePath) => { const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS; while (Date.now() < deadline) { @@ -75,12 +67,15 @@ export async function runStatsCommand( const responsePath = resolvedDeps.joinPath(tempDir, 'response.json'); try { - const forwarded = ['--stats', '--stats-response-path', responsePath]; - if (args.statsBackground) { - forwarded.push('--stats-background'); - } - if (args.statsStop) { - forwarded.push('--stats-stop'); + const forwarded = args.statsCleanup + ? ['--stats', '--stats-response-path', responsePath] + : [ + args.statsStop ? '--stats-daemon-stop' : '--stats-daemon-start', + '--stats-response-path', + responsePath, + ]; + if (!args.statsCleanup && !args.statsBackground && !args.statsStop) { + forwarded.push('--stats-daemon-open-browser'); } if (args.statsCleanup) { forwarded.push('--stats-cleanup'); @@ -94,14 +89,6 @@ export async function runStatsCommand( if (args.logLevel !== 'info') { forwarded.push('--log-level', args.logLevel); } - if (args.statsBackground) { - resolvedDeps.launchAppCommandDetached(appPath, forwarded, args.logLevel, 'stats'); - const startupResult = await resolvedDeps.waitForStatsResponse(responsePath); - if (!startupResult.ok) { - throw new Error(startupResult.error || 'Stats dashboard failed to start.'); - } - return true; - } const attachedExitPromise = resolvedDeps.runAppCommandAttached( appPath, forwarded, diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 2fd64ba..81c435b 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -276,6 +276,16 @@ export function parseCliPrograms( if (statsBackground && statsStop) { throw new Error('Stats background and stop flags cannot be combined.'); } + if ( + normalizedAction && + normalizedAction !== 'cleanup' && + normalizedAction !== 'rebuild' && + normalizedAction !== 'backfill' + ) { + throw new Error( + 'Invalid stats action. Valid values are cleanup, rebuild, or backfill.', + ); + } if (normalizedAction && (statsBackground || statsStop)) { throw new Error('Stats background and stop flags cannot be combined with stats actions.'); } diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 83751a0..2f26d4f 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -536,7 +536,7 @@ exit 0 assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.match( fs.readFileSync(capturePath, 'utf8'), - /^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/, + /^--stats-daemon-start\n--stats-response-path\n.+\n--stats-daemon-open-browser\n--log-level\ndebug\n$/, ); }); }, diff --git a/launcher/mpv.ts b/launcher/mpv.ts index a487b09..dc82a04 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -45,6 +45,8 @@ export function parseMpvArgString(input: string): string[] { let inSingleQuote = false; let inDoubleQuote = false; let escaping = false; + const canEscape = (nextChar: string | undefined): boolean => + nextChar === undefined || nextChar === '"' || nextChar === "'" || nextChar === '\\' || /\s/.test(nextChar); for (let i = 0; i < chars.length; i += 1) { const ch = chars[i] || ''; @@ -65,7 +67,11 @@ export function parseMpvArgString(input: string): string[] { if (inDoubleQuote) { if (ch === '\\') { - escaping = true; + if (canEscape(chars[i + 1])) { + escaping = true; + } else { + current += ch; + } continue; } if (ch === '"') { @@ -77,7 +83,11 @@ export function parseMpvArgString(input: string): string[] { } if (ch === '\\') { - escaping = true; + if (canEscape(chars[i + 1])) { + escaping = true; + } else { + current += ch; + } continue; } if (ch === "'") { @@ -857,8 +867,14 @@ export function runAppCommandAttached( proc.once('error', (error) => { reject(error); }); - proc.once('exit', (code) => { - resolve(code ?? 0); + proc.once('exit', (code, signal) => { + if (code !== null) { + resolve(code); + } else if (signal) { + resolve(128); + } else { + resolve(0); + } }); }); } diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 2f867c7..7654407 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -40,8 +40,10 @@ import { createLogger } from './logger'; import { createUiFeedbackState, beginUpdateProgress, + clearUpdateProgress, endUpdateProgress, showStatusNotification, + showUpdateResult, withUpdateProgress, UiFeedbackState, } from './anki-integration/ui-feedback'; @@ -310,6 +312,8 @@ export class AnkiIntegration { ), }, showOsdNotification: (text: string) => this.showOsdNotification(text), + showUpdateResult: (message: string, success: boolean) => + this.showUpdateResult(message, success), showStatusNotification: (message: string) => this.showStatusNotification(message), showNotification: (noteId, label, errorSuffix) => this.showNotification(noteId, label, errorSuffix), @@ -773,6 +777,12 @@ export class AnkiIntegration { }); } + private clearUpdateProgress(): void { + clearUpdateProgress(this.uiFeedbackState, (timer) => { + clearInterval(timer); + }); + } + private async withUpdateProgress( initialMessage: string, action: () => Promise, @@ -903,7 +913,9 @@ export class AnkiIntegration { const type = this.config.behavior?.notificationType || 'osd'; if (type === 'osd' || type === 'both') { - this.showOsdNotification(message); + this.showUpdateResult(message, true); + } else { + this.clearUpdateProgress(); } if ((type === 'system' || type === 'both') && this.notificationCallback) { @@ -938,6 +950,21 @@ export class AnkiIntegration { } } + private showUpdateResult(message: string, success: boolean): void { + showUpdateResult( + this.uiFeedbackState, + { + clearProgressTimer: (timer) => { + clearInterval(timer); + }, + showOsdNotification: (text) => { + this.showOsdNotification(text); + }, + }, + { message, success }, + ); + } + private mergeFieldValue(existing: string, newValue: string, overwrite: boolean): string { if (overwrite || !existing.trim()) { return newValue; diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 6820fe1..1bb5670 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -75,6 +75,7 @@ interface CardCreationDeps { client: CardCreationClient; mediaGenerator: CardCreationMediaGenerator; showOsdNotification: (text: string) => void; + showUpdateResult: (message: string, success: boolean) => void; showStatusNotification: (message: string) => void; showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise; beginUpdateProgress: (initialMessage: string) => void; @@ -261,8 +262,7 @@ export class CardCreationService { if (this.deps.getConfig().media?.generateImage) { try { - const animatedLeadInSeconds = - await this.deps.getAnimatedImageLeadInSeconds(noteInfo); + const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const imageFilename = this.generateImageFilename(); const imageBuffer = await this.generateImageBuffer( mpvClient.currentVideoPath, @@ -420,8 +420,7 @@ export class CardCreationService { if (this.deps.getConfig().media?.generateImage) { try { - const animatedLeadInSeconds = - await this.deps.getAnimatedImageLeadInSeconds(noteInfo); + const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const imageFilename = this.generateImageFilename(); const imageBuffer = await this.generateImageBuffer( mpvClient.currentVideoPath, @@ -554,7 +553,7 @@ export class CardCreationService { this.deps.trackLastAddedNoteId?.(noteId); } catch (error) { log.error('Failed to create sentence card:', (error as Error).message); - this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`); + this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false); return false; } @@ -651,7 +650,7 @@ export class CardCreationService { }); } catch (error) { log.error('Error creating sentence card:', (error as Error).message); - this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`); + this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false); return false; } } diff --git a/src/anki-integration/ui-feedback.test.ts b/src/anki-integration/ui-feedback.test.ts new file mode 100644 index 0000000..b4c2d7e --- /dev/null +++ b/src/anki-integration/ui-feedback.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + beginUpdateProgress, + createUiFeedbackState, + showProgressTick, + showUpdateResult, +} from './ui-feedback'; + +test('showUpdateResult stops spinner before success notification and suppresses stale ticks', () => { + const state = createUiFeedbackState(); + const osdMessages: string[] = []; + + beginUpdateProgress(state, 'Creating sentence card', () => { + showProgressTick(state, (text) => { + osdMessages.push(text); + }); + }); + + showUpdateResult( + state, + { + clearProgressTimer: (timer) => { + clearInterval(timer); + }, + showOsdNotification: (text) => { + osdMessages.push(text); + }, + }, + { success: true, message: 'Updated card: taberu' }, + ); + + showProgressTick(state, (text) => { + osdMessages.push(text); + }); + + assert.deepEqual(osdMessages, ['Creating sentence card |', '✓ Updated card: taberu']); +}); + +test('showUpdateResult renders failed updates with an x marker', () => { + const state = createUiFeedbackState(); + const osdMessages: string[] = []; + + beginUpdateProgress(state, 'Creating sentence card', () => { + showProgressTick(state, (text) => { + osdMessages.push(text); + }); + }); + + showUpdateResult( + state, + { + clearProgressTimer: (timer) => { + clearInterval(timer); + }, + showOsdNotification: (text) => { + osdMessages.push(text); + }, + }, + { success: false, message: 'Sentence card failed: deck missing' }, + ); + + assert.deepEqual(osdMessages, [ + 'Creating sentence card |', + 'x Sentence card failed: deck missing', + ]); +}); diff --git a/src/anki-integration/ui-feedback.ts b/src/anki-integration/ui-feedback.ts index 09844d7..ea43e70 100644 --- a/src/anki-integration/ui-feedback.ts +++ b/src/anki-integration/ui-feedback.ts @@ -7,6 +7,11 @@ export interface UiFeedbackState { progressFrame: number; } +export interface UiFeedbackResult { + success: boolean; + message: string; +} + export interface UiFeedbackNotificationContext { getNotificationType: () => string | undefined; showOsd: (text: string) => void; @@ -66,6 +71,15 @@ export function endUpdateProgress( state.progressDepth = Math.max(0, state.progressDepth - 1); if (state.progressDepth > 0) return; + clearUpdateProgress(state, clearProgressTimer); +} + +export function clearUpdateProgress( + state: UiFeedbackState, + clearProgressTimer: (timer: ReturnType) => void, +): void { + state.progressDepth = 0; + if (state.progressTimer) { clearProgressTimer(state.progressTimer); state.progressTimer = null; @@ -85,6 +99,19 @@ export function showProgressTick( showOsdNotification(`${state.progressMessage} ${frame}`); } +export function showUpdateResult( + state: UiFeedbackState, + options: { + clearProgressTimer: (timer: ReturnType) => void; + showOsdNotification: (text: string) => void; + }, + result: UiFeedbackResult, +): void { + clearUpdateProgress(state, options.clearProgressTimer); + const prefix = result.success ? '✓' : 'x'; + options.showOsdNotification(`${prefix} ${result.message}`); +} + export async function withUpdateProgress( state: UiFeedbackState, options: UiFeedbackOptions, diff --git a/src/core/services/subtitle-ws.test.ts b/src/core/services/subtitle-ws.test.ts index ac4be72..011594f 100644 --- a/src/core/services/subtitle-ws.test.ts +++ b/src/core/services/subtitle-ws.test.ts @@ -130,6 +130,30 @@ test('serializeSubtitleMarkup preserves tooltip attrs and name-match precedence' assert.doesNotMatch(markup, /data-frequency-rank="12"|data-jlpt-level="N5"|word-jlpt-n5/); }); +test('serializeSubtitleMarkup keeps filtered tokens hoverable without annotation attrs', () => { + const payload: SubtitleData = { + text: 'は', + tokens: [ + { + surface: 'は', + reading: 'は', + headword: 'は', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + isNameMatch: false, + }, + ], + }; + + const markup = serializeSubtitleMarkup(payload, frequencyOptions); + assert.equal(markup, ''); +}); + test('serializeSubtitleWebsocketMessage emits sentence payload', () => { const payload: SubtitleData = { text: '字幕', diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index b8def55..c228bb0 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -1305,7 +1305,7 @@ test('tokenizeSubtitle ignores frequency lookup failures', async () => { assert.equal(result.tokens?.[0]?.frequencyRank, undefined); }); -test('tokenizeSubtitle skips frequency rank when Yomitan token is enriched as particle by mecab pos1', async () => { +test('tokenizeSubtitle keeps standalone particle token hoverable while clearing annotation metadata', async () => { const result = await tokenizeSubtitle( 'は', makeDeps({ @@ -1350,9 +1350,33 @@ test('tokenizeSubtitle skips frequency rank when Yomitan token is enriched as pa }), ); - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.pos1, '助詞'); - assert.equal(result.tokens?.[0]?.frequencyRank, undefined); + assert.equal(result.text, 'は'); + assert.deepEqual( + result.tokens?.map((token) => ({ + surface: token.surface, + reading: token.reading, + headword: token.headword, + pos1: token.pos1, + isKnown: token.isKnown, + isNPlusOneTarget: token.isNPlusOneTarget, + isNameMatch: token.isNameMatch, + jlptLevel: token.jlptLevel, + frequencyRank: token.frequencyRank, + })), + [ + { + surface: 'は', + reading: 'は', + headword: 'は', + pos1: '助詞', + isKnown: false, + isNPlusOneTarget: false, + isNameMatch: false, + jlptLevel: undefined, + frequencyRank: undefined, + }, + ], + ); }); test('tokenizeSubtitle keeps frequency rank when mecab tags classify token as content-bearing', async () => { @@ -1460,7 +1484,7 @@ test('tokenizeSubtitle skips JLPT level for excluded demonstratives', async () = assert.equal(result.tokens?.[0]?.jlptLevel, undefined); }); -test('tokenizeSubtitle excludes repeated kana interjections from annotation payloads entirely', async () => { +test('tokenizeSubtitle keeps repeated kana interjections tokenized while clearing annotation metadata', async () => { const result = await tokenizeSubtitle( 'ああ', makeDeps({ @@ -1491,7 +1515,29 @@ test('tokenizeSubtitle excludes repeated kana interjections from annotation payl }), ); - assert.deepEqual(result, { text: 'ああ', tokens: null }); + assert.equal(result.text, 'ああ'); + assert.deepEqual( + result.tokens?.map((token) => ({ + surface: token.surface, + headword: token.headword, + reading: token.reading, + jlptLevel: token.jlptLevel, + frequencyRank: token.frequencyRank, + isKnown: token.isKnown, + isNPlusOneTarget: token.isNPlusOneTarget, + })), + [ + { + surface: 'ああ', + headword: 'ああ', + reading: 'ああ', + jlptLevel: undefined, + frequencyRank: undefined, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + ); }); test('tokenizeSubtitle assigns JLPT level to Yomitan tokens', async () => { @@ -2578,7 +2624,15 @@ test('tokenizeSubtitle keeps correct MeCab pos1 enrichment when Yomitan offsets const gaToken = result.tokens?.find((token) => token.surface === 'が'); const desuToken = result.tokens?.find((token) => token.surface === 'です'); assert.equal(gaToken?.pos1, '助詞'); + assert.equal(gaToken?.isKnown, false); + assert.equal(gaToken?.isNPlusOneTarget, false); + assert.equal(gaToken?.jlptLevel, undefined); + assert.equal(gaToken?.frequencyRank, undefined); assert.equal(desuToken?.pos1, '助動詞'); + assert.equal(desuToken?.isKnown, false); + assert.equal(desuToken?.isNPlusOneTarget, false); + assert.equal(desuToken?.jlptLevel, undefined); + assert.equal(desuToken?.frequencyRank, undefined); assert.equal(targets.length, 1); assert.equal(targets[0]?.surface, '仮面'); }); @@ -3056,7 +3110,7 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false); }); -test('tokenizeSubtitle excludes mecab-tagged interjections from annotation payloads entirely', async () => { +test('tokenizeSubtitle keeps mecab-tagged interjections tokenized while clearing annotation metadata', async () => { const result = await tokenizeSubtitle( 'ぐはっ', makeDepsFromYomitanTokens([{ surface: 'ぐはっ', reading: 'ぐはっ', headword: 'ぐはっ' }], { @@ -3080,10 +3134,34 @@ test('tokenizeSubtitle excludes mecab-tagged interjections from annotation paylo }), ); - assert.deepEqual(result, { text: 'ぐはっ', tokens: null }); + assert.equal(result.text, 'ぐはっ'); + assert.deepEqual( + result.tokens?.map((token) => ({ + surface: token.surface, + headword: token.headword, + reading: token.reading, + pos1: token.pos1, + jlptLevel: token.jlptLevel, + frequencyRank: token.frequencyRank, + isKnown: token.isKnown, + isNPlusOneTarget: token.isNPlusOneTarget, + })), + [ + { + surface: 'ぐはっ', + headword: 'ぐはっ', + reading: 'ぐはっ', + pos1: '感動詞', + jlptLevel: undefined, + frequencyRank: undefined, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + ); }); -test('tokenizeSubtitle keeps visible text while excluding interjections from mixed annotation payloads', async () => { +test('tokenizeSubtitle keeps excluded interjections hoverable while clearing only their annotation metadata', async () => { const result = await tokenizeSubtitle( 'ぐはっ 猫', makeDeps({ @@ -3147,8 +3225,261 @@ test('tokenizeSubtitle keeps visible text while excluding interjections from mix result.tokens?.map((token) => ({ surface: token.surface, headword: token.headword, + frequencyRank: token.frequencyRank, + jlptLevel: token.jlptLevel, })), - [{ surface: '猫', headword: '猫' }], + [ + { surface: 'ぐはっ', headword: 'ぐはっ', frequencyRank: undefined, jlptLevel: undefined }, + { surface: '猫', headword: '猫', frequencyRank: 11, jlptLevel: 'N5' }, + ], + ); +}); + +test('tokenizeSubtitle keeps explanatory ending variants hoverable while clearing only their annotation metadata', async () => { + const result = await tokenizeSubtitle( + '猫んです', + makeDepsFromYomitanTokens( + [ + { surface: '猫', reading: 'ねこ', headword: '猫' }, + { surface: 'んです', reading: 'んです', headword: 'ん' }, + ], + { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === '猫' ? 11 : 500), + getJlptLevel: (text) => (text === '猫' ? 'N5' : null), + tokenizeWithMecab: async () => [ + { + headword: '猫', + surface: '猫', + reading: 'ネコ', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '一般', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'ん', + surface: 'ん', + reading: 'ン', + startPos: 1, + endPos: 2, + partOfSpeech: PartOfSpeech.other, + pos1: '名詞', + pos2: '非自立', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'です', + surface: 'です', + reading: 'デス', + startPos: 2, + endPos: 4, + partOfSpeech: PartOfSpeech.bound_auxiliary, + pos1: '助動詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + }, + ), + ); + + assert.equal(result.text, '猫んです'); + assert.deepEqual( + result.tokens?.map((token) => ({ + surface: token.surface, + headword: token.headword, + jlptLevel: token.jlptLevel, + frequencyRank: token.frequencyRank, + })), + [ + { surface: '猫', headword: '猫', jlptLevel: 'N5', frequencyRank: 11 }, + { surface: 'んです', headword: 'ん', jlptLevel: undefined, frequencyRank: undefined }, + ], + ); +}); + +test('tokenizeSubtitle keeps standalone grammar-only tokens hoverable while clearing only their annotation metadata', async () => { + const result = await tokenizeSubtitle( + '私はこの猫です', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === '私' ? 50 : text === '猫' ? 11 : 500), + getJlptLevel: (text) => (text === '私' ? 'N5' : text === '猫' ? 'N5' : null), + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async (script: string) => { + if (script.includes('getTermFrequencies')) { + return []; + } + + return [ + { + source: 'scanning-parser', + index: 0, + content: [ + [{ text: '私', reading: 'わたし', headwords: [[{ term: '私' }]] }], + [{ text: 'は', reading: 'は', headwords: [[{ term: 'は' }]] }], + [{ text: 'この', reading: 'この', headwords: [[{ term: 'この' }]] }], + [{ text: '猫', reading: 'ねこ', headwords: [[{ term: '猫' }]] }], + [{ text: 'です', reading: 'です', headwords: [[{ term: 'です' }]] }], + ], + }, + ]; + }, + }, + }) as unknown as Electron.BrowserWindow, + tokenizeWithMecab: async () => [ + { + headword: '私', + surface: '私', + reading: 'ワタシ', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '代名詞', + isMerged: true, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'は', + surface: 'は', + reading: 'ハ', + startPos: 1, + endPos: 2, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + pos2: '係助詞', + isMerged: true, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'この', + surface: 'この', + reading: 'コノ', + startPos: 2, + endPos: 4, + partOfSpeech: PartOfSpeech.other, + pos1: '連体詞', + isMerged: true, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: '猫', + surface: '猫', + reading: 'ネコ', + startPos: 4, + endPos: 5, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '一般', + isMerged: true, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'です', + surface: 'です', + reading: 'デス', + startPos: 5, + endPos: 7, + partOfSpeech: PartOfSpeech.bound_auxiliary, + pos1: '助動詞', + isMerged: true, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + }), + ); + + assert.equal(result.text, '私はこの猫です'); + assert.deepEqual( + result.tokens?.map((token) => ({ + surface: token.surface, + headword: token.headword, + frequencyRank: token.frequencyRank, + jlptLevel: token.jlptLevel, + })), + [ + { surface: '私', headword: '私', frequencyRank: 50, jlptLevel: 'N5' }, + { surface: 'は', headword: 'は', frequencyRank: undefined, jlptLevel: undefined }, + { surface: 'この', headword: 'この', frequencyRank: undefined, jlptLevel: undefined }, + { surface: '猫', headword: '猫', frequencyRank: 11, jlptLevel: 'N5' }, + { surface: 'です', headword: 'です', frequencyRank: undefined, jlptLevel: undefined }, + ], + ); +}); + +test('tokenizeSubtitle keeps trailing quote-particle merged tokens hoverable while clearing only their annotation metadata', async () => { + const result = await tokenizeSubtitle( + 'どうしてもって', + makeDepsFromYomitanTokens([{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }], { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === 'どうしても' ? 123 : null), + getJlptLevel: (text) => (text === 'どうしても' ? 'N3' : null), + tokenizeWithMecab: async () => [ + { + headword: 'どうしても', + surface: 'どうしても', + reading: 'ドウシテモ', + startPos: 0, + endPos: 5, + partOfSpeech: PartOfSpeech.other, + pos1: '副詞', + pos2: '一般', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'って', + surface: 'って', + reading: 'ッテ', + startPos: 5, + endPos: 7, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + pos2: '格助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + getMinSentenceWordsForNPlusOne: () => 1, + }), + ); + + assert.equal(result.text, 'どうしてもって'); + assert.deepEqual( + result.tokens?.map((token) => ({ + surface: token.surface, + headword: token.headword, + jlptLevel: token.jlptLevel, + frequencyRank: token.frequencyRank, + })), + [ + { + surface: 'どうしてもって', + headword: 'どうしても', + jlptLevel: undefined, + frequencyRank: undefined, + }, + ], ); }); diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index 2e476c3..240a97a 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -178,7 +178,7 @@ async function applyAnnotationStage( ); } -async function filterSubtitleAnnotationTokens(tokens: MergedToken[]): Promise { +async function stripSubtitleAnnotationMetadata(tokens: MergedToken[]): Promise { if (tokens.length === 0) { return tokens; } @@ -188,9 +188,7 @@ async function filterSubtitleAnnotationTokens(tokens: MergedToken[]): Promise !annotationStage.shouldExcludeTokenFromSubtitleAnnotations(token), - ); + return tokens.map((token) => annotationStage.stripSubtitleAnnotationMetadata(token)); } export function createTokenizerDepsRuntime( @@ -721,12 +719,12 @@ export async function tokenizeSubtitle( const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions); if (yomitanTokens && yomitanTokens.length > 0) { - const filteredTokens = await filterSubtitleAnnotationTokens( + const annotatedTokens = await stripSubtitleAnnotationMetadata( await applyAnnotationStage(yomitanTokens, deps, annotationOptions), ); return { text: displayText, - tokens: filteredTokens.length > 0 ? filteredTokens : null, + tokens: annotatedTokens.length > 0 ? annotatedTokens : null, }; } diff --git a/src/core/services/tokenizer/annotation-stage.test.ts b/src/core/services/tokenizer/annotation-stage.test.ts index dd9fdf8..9af0661 100644 --- a/src/core/services/tokenizer/annotation-stage.test.ts +++ b/src/core/services/tokenizer/annotation-stage.test.ts @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { MergedToken, PartOfSpeech } from '../../../types'; -import { annotateTokens, AnnotationStageDeps } from './annotation-stage'; +import { + annotateTokens, + AnnotationStageDeps, + shouldExcludeTokenFromSubtitleAnnotations, + stripSubtitleAnnotationMetadata, +} from './annotation-stage'; function makeToken(overrides: Partial = {}): MergedToken { return { @@ -150,6 +155,170 @@ test('annotateTokens handles JLPT disabled and eligibility exclusion paths', () assert.equal(excludedLookupCalls, 0); }); +test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory ending variants', () => { + const tokens = [ + makeToken({ + surface: 'んです', + headword: 'ん', + reading: 'ンデス', + pos1: '名詞|助動詞', + pos2: '非自立', + }), + makeToken({ + surface: 'のだ', + headword: 'の', + reading: 'ノダ', + pos1: '名詞|助動詞', + pos2: '非自立', + }), + makeToken({ + surface: 'んだ', + headword: 'ん', + reading: 'ンダ', + pos1: '名詞|助動詞', + pos2: '非自立', + }), + makeToken({ + surface: 'のです', + headword: 'の', + reading: 'ノデス', + pos1: '名詞|助動詞', + pos2: '非自立', + }), + makeToken({ + surface: 'なんです', + headword: 'だ', + reading: 'ナンデス', + pos1: '助動詞|名詞|助動詞', + pos2: '|非自立', + }), + makeToken({ + surface: 'んでした', + headword: 'ん', + reading: 'ンデシタ', + pos1: '助動詞|助動詞|助動詞', + }), + makeToken({ + surface: 'のでは', + headword: 'の', + reading: 'ノデハ', + pos1: '助詞|接続詞', + }), + ]; + + for (const token of tokens) { + assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); + } +}); + +test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical tokens outside explanatory ending family', () => { + const token = makeToken({ + surface: '問題', + headword: '問題', + reading: 'モンダイ', + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '一般', + }); + + assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); +}); + +test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particles auxiliaries and adnominals', () => { + const tokens = [ + makeToken({ + surface: 'は', + headword: 'は', + reading: 'ハ', + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + }), + makeToken({ + surface: 'です', + headword: 'です', + reading: 'デス', + partOfSpeech: PartOfSpeech.bound_auxiliary, + pos1: '助動詞', + }), + makeToken({ + surface: 'この', + headword: 'この', + reading: 'コノ', + partOfSpeech: PartOfSpeech.other, + pos1: '連体詞', + }), + ]; + + for (const token of tokens) { + assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); + } +}); + +test('shouldExcludeTokenFromSubtitleAnnotations keeps mixed content tokens with trailing helpers', () => { + const token = makeToken({ + surface: '行きます', + headword: '行く', + reading: 'イキマス', + partOfSpeech: PartOfSpeech.verb, + pos1: '動詞|助動詞', + pos2: '自立', + }); + + assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); +}); + +test('shouldExcludeTokenFromSubtitleAnnotations excludes merged lexical tokens with trailing quote particles', () => { + const token = makeToken({ + surface: 'どうしてもって', + headword: 'どうしても', + reading: 'ドウシテモッテ', + partOfSpeech: PartOfSpeech.other, + pos1: '副詞|助詞', + pos2: '一般|格助詞', + }); + + assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); +}); + +test('stripSubtitleAnnotationMetadata keeps token hover data while clearing annotation fields', () => { + const token = makeToken({ + surface: 'は', + headword: 'は', + reading: 'ハ', + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + isKnown: true, + isNPlusOneTarget: true, + isNameMatch: true, + jlptLevel: 'N5', + frequencyRank: 12, + }); + + assert.deepEqual(stripSubtitleAnnotationMetadata(token), { + ...token, + isKnown: false, + isNPlusOneTarget: false, + isNameMatch: false, + jlptLevel: undefined, + frequencyRank: undefined, + }); +}); + +test('stripSubtitleAnnotationMetadata leaves content tokens unchanged', () => { + const token = makeToken({ + surface: '猫', + headword: '猫', + reading: 'ネコ', + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + isKnown: true, + jlptLevel: 'N5', + frequencyRank: 42, + }); + + assert.strictEqual(stripSubtitleAnnotationMetadata(token), token); +}); + test('annotateTokens prioritizes name matches over n+1, frequency, and JLPT when enabled', () => { let jlptLookupCalls = 0; const tokens = [ diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index 026c9c7..5617b8f 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -25,6 +25,45 @@ const SUBTITLE_ANNOTATION_EXCLUDED_TERMS = new Set([ 'ふう', 'ほう', ]); +const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES = ['ん', 'の', 'なん', 'なの']; +const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES = [ + 'だ', + 'です', + 'でした', + 'だった', + 'では', + 'じゃ', + 'でしょう', + 'だろう', +] as const; +const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [ + '', + 'か', + 'ね', + 'よ', + 'な', + 'よね', + 'かな', + 'かね', +] as const; +const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set( + SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) => + SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) => + SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES.map( + (particle) => `${prefix}${core}${particle}`, + ), + ), + ), +); +const SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES = new Set([ + 'って', + 'ってよ', + 'ってね', + 'ってな', + 'ってさ', + 'ってか', + 'ってば', +]); const jlptLevelLookupCaches = new WeakMap< (text: string) => JlptLevel | null, @@ -60,6 +99,7 @@ function normalizePos1Tag(pos1: string | undefined): string { } const SUBTITLE_ANNOTATION_EXCLUDED_POS1 = new Set(['感動詞']); +const SUBTITLE_ANNOTATION_GRAMMAR_ONLY_POS1 = new Set(['助詞', '助動詞', '連体詞']); function splitNormalizedTagParts(normalizedTag: string): string[] { if (!normalizedTag) { @@ -84,7 +124,36 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet SUBTITLE_ANNOTATION_EXCLUDED_POS1.has(part)); + if (parts.some((part) => SUBTITLE_ANNOTATION_EXCLUDED_POS1.has(part))) { + return true; + } + + return parts.length > 0 && parts.every((part) => SUBTITLE_ANNOTATION_GRAMMAR_ONLY_POS1.has(part)); +} + +function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean { + const normalizedSurface = normalizeJlptTextForExclusion(token.surface); + const normalizedHeadword = normalizeJlptTextForExclusion(token.headword); + if (!normalizedSurface || !normalizedHeadword || !normalizedSurface.startsWith(normalizedHeadword)) { + return false; + } + + const suffix = normalizedSurface.slice(normalizedHeadword.length); + if (!SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES.has(suffix)) { + return false; + } + + const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1)); + if (pos1Parts.length < 2) { + return false; + } + + const [leadingPos1, ...trailingPos1] = pos1Parts; + if (!leadingPos1 || SUBTITLE_ANNOTATION_GRAMMAR_ONLY_POS1.has(leadingPos1)) { + return false; + } + + return trailingPos1.length > 0 && trailingPos1.every((part) => part === '助詞'); } function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet { @@ -520,12 +589,7 @@ function isJlptEligibleToken(token: MergedToken): boolean { } function isExcludedFromSubtitleAnnotationsByTerm(token: MergedToken): boolean { - const candidates = [ - resolveJlptLookupText(token), - token.surface, - token.headword, - token.reading, - ].filter( + const candidates = [token.surface, token.reading, resolveJlptLookupText(token)].filter( (candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0, ); @@ -542,7 +606,9 @@ function isExcludedFromSubtitleAnnotationsByTerm(token: MergedToken): boolean { if ( SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(trimmedCandidate) || - SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(normalizedCandidate) + SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(normalizedCandidate) || + SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS.has(trimmedCandidate) || + SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS.has(normalizedCandidate) ) { return true; } @@ -565,9 +631,28 @@ export function shouldExcludeTokenFromSubtitleAnnotations(token: MergedToken): b return true; } + if (isExcludedTrailingParticleMergedToken(token)) { + return true; + } + return isExcludedFromSubtitleAnnotationsByTerm(token); } +export function stripSubtitleAnnotationMetadata(token: MergedToken): MergedToken { + if (!shouldExcludeTokenFromSubtitleAnnotations(token)) { + return token; + } + + return { + ...token, + isKnown: false, + isNPlusOneTarget: false, + isNameMatch: false, + jlptLevel: undefined, + frequencyRank: undefined, + }; +} + function computeTokenKnownStatus( token: MergedToken, isKnownWord: (text: string) => boolean, diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index f07110a..dd1f7a2 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -11,6 +11,7 @@ import { shouldDetachBackgroundLaunch, shouldHandleHelpOnlyAtEntry, shouldHandleLaunchMpvAtEntry, + shouldHandleStatsDaemonCommandAtEntry, } from './main-entry-runtime'; test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { @@ -71,6 +72,25 @@ test('launch-mpv entry helpers detect and normalize targets', () => { ]); }); +test('stats-daemon entry helper detects internal daemon commands', () => { + assert.equal( + shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-start'], {}), + true, + ); + assert.equal( + shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-stop'], {}), + true, + ); + assert.equal( + shouldHandleStatsDaemonCommandAtEntry( + ['SubMiner.AppImage', '--stats-daemon-start'], + { ELECTRON_RUN_AS_NODE: '1' }, + ), + false, + ); + assert.equal(shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--start'], {}), false); +}); + test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => { const env = sanitizeStartupEnv({ VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar', diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 90a04ce..b6405fa 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -112,6 +112,14 @@ export function shouldHandleLaunchMpvAtEntry(argv: string[], env: NodeJS.Process return parseCliArgs(argv).launchMpv; } +export function shouldHandleStatsDaemonCommandAtEntry( + argv: string[], + env: NodeJS.ProcessEnv, +): boolean { + if (env.ELECTRON_RUN_AS_NODE === '1') return false; + return argv.includes('--stats-daemon-start') || argv.includes('--stats-daemon-stop'); +} + export function normalizeLaunchMpvTargets(argv: string[]): string[] { return parseCliArgs(argv).launchMpvTargets; } diff --git a/src/main-entry.ts b/src/main-entry.ts index eb337f0..5012813 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -12,9 +12,11 @@ import { shouldDetachBackgroundLaunch, shouldHandleHelpOnlyAtEntry, shouldHandleLaunchMpvAtEntry, + shouldHandleStatsDaemonCommandAtEntry, } from './main-entry-runtime'; import { requestSingleInstanceLockEarly } from './main/early-single-instance'; import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; +import { runStatsDaemonControlFromProcess } from './stats-daemon-entry'; const DEFAULT_TEXTHOOKER_PORT = 5174; @@ -69,6 +71,11 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { ); app.exit(result.ok ? 0 : 1); }); +} else if (shouldHandleStatsDaemonCommandAtEntry(process.argv, process.env)) { + void app.whenReady().then(async () => { + const exitCode = await runStatsDaemonControlFromProcess(app.getPath('userData')); + app.exit(exitCode); + }); } else { const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); if (!gotSingleInstanceLock) { diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index aa22c9f..64b8309 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -682,7 +682,7 @@ test('renderSubtitle preserves unsupported punctuation while keeping it non-inte } }); -test('renderSubtitle keeps excluded interjection text visible while only rendering remaining tokens as interactive', () => { +test('renderSubtitle keeps excluded interjection tokens hoverable while rendering them without annotation styling', () => { const restoreDocument = installFakeDocument(); try { @@ -718,13 +718,19 @@ test('renderSubtitle keeps excluded interjection text visible while only renderi renderer.renderSubtitle({ text: 'ぐはっ 猫', - tokens: [createToken({ surface: '猫', headword: '猫', reading: 'ねこ' })], + tokens: [ + createToken({ surface: 'ぐはっ', headword: 'ぐはっ', reading: 'ぐはっ' }), + createToken({ surface: '猫', headword: '猫', reading: 'ねこ' }), + ], }); assert.equal(subtitleRoot.textContent, 'ぐはっ 猫'); assert.deepEqual( collectWordNodes(subtitleRoot).map((node) => [node.textContent, node.dataset.tokenIndex]), - [['猫', '0']], + [ + ['ぐはっ', '0'], + ['猫', '1'], + ], ); } finally { restoreDocument(); diff --git a/src/stats-daemon-control.test.ts b/src/stats-daemon-control.test.ts new file mode 100644 index 0000000..acacc29 --- /dev/null +++ b/src/stats-daemon-control.test.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRunStatsDaemonControlHandler } from './stats-daemon-control'; + +test('stats daemon control reuses live daemon and writes launcher response', async () => { + const calls: string[] = []; + const responses: Array<{ path: string; payload: { ok: boolean; url?: string; error?: string } }> = + []; + const handler = createRunStatsDaemonControlHandler({ + statePath: '/tmp/stats-daemon.json', + readState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }), + removeState: () => { + calls.push('removeState'); + }, + isProcessAlive: (pid) => { + calls.push(`isProcessAlive:${pid}`); + return true; + }, + resolveUrl: (state) => `http://127.0.0.1:${state.port}`, + spawnDaemon: async () => { + calls.push('spawnDaemon'); + return 1; + }, + waitForDaemonResponse: async () => { + calls.push('waitForDaemonResponse'); + return { ok: true, url: 'http://127.0.0.1:5175' }; + }, + openExternal: async (url) => { + calls.push(`openExternal:${url}`); + }, + writeResponse: (responsePath, payload) => { + responses.push({ path: responsePath, payload }); + }, + killProcess: () => { + calls.push('killProcess'); + }, + sleep: async () => {}, + }); + + const exitCode = await handler({ + action: 'start', + responsePath: '/tmp/response.json', + openBrowser: true, + daemonScriptPath: '/tmp/stats-daemon-runner.js', + userDataPath: '/tmp/SubMiner', + }); + + assert.equal(exitCode, 0); + assert.deepEqual(calls, ['isProcessAlive:4242', 'openExternal:http://127.0.0.1:5175']); + assert.deepEqual(responses, [ + { + path: '/tmp/response.json', + payload: { ok: true, url: 'http://127.0.0.1:5175' }, + }, + ]); +}); + +test('stats daemon control clears stale state, starts daemon, and waits for response', async () => { + const calls: string[] = []; + const handler = createRunStatsDaemonControlHandler({ + statePath: '/tmp/stats-daemon.json', + readState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }), + removeState: () => { + calls.push('removeState'); + }, + isProcessAlive: (pid) => { + calls.push(`isProcessAlive:${pid}`); + return false; + }, + resolveUrl: (state) => `http://127.0.0.1:${state.port}`, + spawnDaemon: async (options) => { + calls.push(`spawnDaemon:${options.scriptPath}:${options.responsePath}:${options.userDataPath}`); + return 999; + }, + waitForDaemonResponse: async (responsePath) => { + calls.push(`waitForDaemonResponse:${responsePath}`); + return { ok: true, url: 'http://127.0.0.1:5175' }; + }, + openExternal: async (url) => { + calls.push(`openExternal:${url}`); + }, + writeResponse: () => { + calls.push('writeResponse'); + }, + killProcess: () => { + calls.push('killProcess'); + }, + sleep: async () => {}, + }); + + const exitCode = await handler({ + action: 'start', + responsePath: '/tmp/response.json', + openBrowser: false, + daemonScriptPath: '/tmp/stats-daemon-runner.js', + userDataPath: '/tmp/SubMiner', + }); + + assert.equal(exitCode, 0); + assert.deepEqual(calls, [ + 'isProcessAlive:4242', + 'removeState', + 'spawnDaemon:/tmp/stats-daemon-runner.js:/tmp/response.json:/tmp/SubMiner', + 'waitForDaemonResponse:/tmp/response.json', + ]); +}); + +test('stats daemon control stops live daemon and treats stale state as success', async () => { + const responses: Array<{ path: string; payload: { ok: boolean; url?: string; error?: string } }> = + []; + const calls: string[] = []; + let aliveChecks = 0; + const handler = createRunStatsDaemonControlHandler({ + statePath: '/tmp/stats-daemon.json', + readState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }), + removeState: () => { + calls.push('removeState'); + }, + isProcessAlive: (pid) => { + aliveChecks += 1; + calls.push(`isProcessAlive:${pid}:${aliveChecks}`); + return aliveChecks === 1; + }, + resolveUrl: (state) => `http://127.0.0.1:${state.port}`, + spawnDaemon: async () => 1, + waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), + openExternal: async () => {}, + writeResponse: (responsePath, payload) => { + responses.push({ path: responsePath, payload }); + }, + killProcess: (pid, signal) => { + calls.push(`killProcess:${pid}:${signal}`); + }, + sleep: async () => {}, + }); + + const exitCode = await handler({ + action: 'stop', + responsePath: '/tmp/response.json', + openBrowser: false, + daemonScriptPath: '/tmp/stats-daemon-runner.js', + userDataPath: '/tmp/SubMiner', + }); + + assert.equal(exitCode, 0); + assert.deepEqual(calls, [ + 'isProcessAlive:4242:1', + 'killProcess:4242:SIGTERM', + 'isProcessAlive:4242:2', + 'removeState', + ]); + assert.deepEqual(responses, [ + { + path: '/tmp/response.json', + payload: { ok: true }, + }, + ]); +}); diff --git a/src/stats-daemon-control.ts b/src/stats-daemon-control.ts new file mode 100644 index 0000000..a51e6a6 --- /dev/null +++ b/src/stats-daemon-control.ts @@ -0,0 +1,102 @@ +import type { BackgroundStatsServerState } from './main/runtime/stats-daemon'; +import type { StatsCliCommandResponse } from './main/runtime/stats-cli-command'; + +export type StatsDaemonControlAction = 'start' | 'stop'; + +export type StatsDaemonControlArgs = { + action: StatsDaemonControlAction; + responsePath?: string; + openBrowser: boolean; + daemonScriptPath: string; + userDataPath: string; +}; + +type SpawnStatsDaemonOptions = { + scriptPath: string; + responsePath?: string; + userDataPath: string; +}; + +export function createRunStatsDaemonControlHandler(deps: { + statePath: string; + readState: () => BackgroundStatsServerState | null; + removeState: () => void; + isProcessAlive: (pid: number) => boolean; + resolveUrl: (state: Pick) => string; + spawnDaemon: (options: SpawnStatsDaemonOptions) => Promise | number; + waitForDaemonResponse: (responsePath: string) => Promise; + openExternal: (url: string) => Promise; + writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void; + killProcess: (pid: number, signal: NodeJS.Signals) => void; + sleep: (ms: number) => Promise; +}) { + const writeResponseSafe = ( + responsePath: string | undefined, + payload: StatsCliCommandResponse, + ): void => { + if (!responsePath) return; + deps.writeResponse(responsePath, payload); + }; + + return async (args: StatsDaemonControlArgs): Promise => { + if (args.action === 'start') { + const state = deps.readState(); + if (state) { + if (deps.isProcessAlive(state.pid)) { + const url = deps.resolveUrl(state); + writeResponseSafe(args.responsePath, { ok: true, url }); + if (args.openBrowser) { + await deps.openExternal(url); + } + return 0; + } + deps.removeState(); + } + + if (!args.responsePath) { + throw new Error('Missing --stats-response-path for stats daemon start.'); + } + + await deps.spawnDaemon({ + scriptPath: args.daemonScriptPath, + responsePath: args.responsePath, + userDataPath: args.userDataPath, + }); + const response = await deps.waitForDaemonResponse(args.responsePath); + if (response.ok && args.openBrowser && response.url) { + await deps.openExternal(response.url); + } + return response.ok ? 0 : 1; + } + + const state = deps.readState(); + if (!state) { + deps.removeState(); + writeResponseSafe(args.responsePath, { ok: true }); + return 0; + } + + if (!deps.isProcessAlive(state.pid)) { + deps.removeState(); + writeResponseSafe(args.responsePath, { ok: true }); + return 0; + } + + deps.killProcess(state.pid, 'SIGTERM'); + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (!deps.isProcessAlive(state.pid)) { + deps.removeState(); + writeResponseSafe(args.responsePath, { ok: true }); + return 0; + } + await deps.sleep(50); + } + + writeResponseSafe(args.responsePath, { + ok: false, + error: 'Timed out stopping background stats server.', + }); + return 1; + }; +} diff --git a/src/stats-daemon-entry.ts b/src/stats-daemon-entry.ts new file mode 100644 index 0000000..0099f9e --- /dev/null +++ b/src/stats-daemon-entry.ts @@ -0,0 +1,135 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { shell } from 'electron'; +import { sanitizeStartupEnv } from './main-entry-runtime'; +import { + isBackgroundStatsServerProcessAlive, + readBackgroundStatsServerState, + removeBackgroundStatsServerState, + resolveBackgroundStatsServerUrl, +} from './main/runtime/stats-daemon'; +import { + createRunStatsDaemonControlHandler, + type StatsDaemonControlArgs, +} from './stats-daemon-control'; +import { + type StatsCliCommandResponse, + writeStatsCliCommandResponse, +} from './main/runtime/stats-cli-command'; + +const STATS_DAEMON_RESPONSE_TIMEOUT_MS = 12_000; + +function readFlagValue(argv: string[], flag: string): string | undefined { + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg) continue; + if (arg === flag) { + const value = argv[i + 1]; + if (value && !value.startsWith('--')) { + return value; + } + return undefined; + } + if (arg.startsWith(`${flag}=`)) { + return arg.split('=', 2)[1]; + } + } + return undefined; +} + +function hasFlag(argv: string[], flag: string): boolean { + return argv.includes(flag); +} + +function parseControlArgs(argv: string[], userDataPath: string): StatsDaemonControlArgs { + return { + action: hasFlag(argv, '--stats-daemon-stop') ? 'stop' : 'start', + responsePath: readFlagValue(argv, '--stats-response-path'), + openBrowser: hasFlag(argv, '--stats-daemon-open-browser'), + daemonScriptPath: path.join(__dirname, 'stats-daemon-runner.js'), + userDataPath, + }; +} + +async function waitForDaemonResponse(responsePath: string): Promise { + const deadline = Date.now() + STATS_DAEMON_RESPONSE_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + if (fs.existsSync(responsePath)) { + return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsCliCommandResponse; + } + } catch { + // retry until timeout + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + return { + ok: false, + error: 'Timed out waiting for stats daemon startup response.', + }; +} + +export async function runStatsDaemonControlFromProcess(userDataPath: string): Promise { + const args = parseControlArgs(process.argv, userDataPath); + const statePath = path.join(userDataPath, 'stats-daemon.json'); + + const writeFailureResponse = (message: string): void => { + if (args.responsePath) { + try { + writeStatsCliCommandResponse(args.responsePath, { + ok: false, + error: message, + }); + } catch { + // ignore secondary response-write failures + } + } + }; + + const handler = createRunStatsDaemonControlHandler({ + statePath, + readState: () => readBackgroundStatsServerState(statePath), + removeState: () => { + removeBackgroundStatsServerState(statePath); + }, + isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid), + resolveUrl: (state) => resolveBackgroundStatsServerUrl(state), + spawnDaemon: async (options) => { + const childArgs = [options.scriptPath, '--stats-user-data-path', options.userDataPath]; + if (options.responsePath) { + childArgs.push('--stats-response-path', options.responsePath); + } + const logLevel = readFlagValue(process.argv, '--log-level'); + if (logLevel) { + childArgs.push('--log-level', logLevel); + } + const child = spawn(process.execPath, childArgs, { + detached: true, + stdio: 'ignore', + env: { + ...sanitizeStartupEnv(process.env), + ELECTRON_RUN_AS_NODE: '1', + }, + }); + child.unref(); + return child.pid ?? 0; + }, + waitForDaemonResponse, + openExternal: async (url) => shell.openExternal(url), + writeResponse: writeStatsCliCommandResponse, + killProcess: (pid, signal) => { + process.kill(pid, signal); + }, + sleep: async (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }); + + try { + return await handler(args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeFailureResponse(message); + return 1; + } +} diff --git a/src/stats-daemon-runner.ts b/src/stats-daemon-runner.ts new file mode 100644 index 0000000..2210b01 --- /dev/null +++ b/src/stats-daemon-runner.ts @@ -0,0 +1,225 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { ConfigService } from './config/service'; +import { createLogger, setLogLevel } from './logger'; +import { ImmersionTrackerService } from './core/services/immersion-tracker-service'; +import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher'; +import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter'; +import { startStatsServer } from './core/services/stats-server'; +import { + removeBackgroundStatsServerState, + writeBackgroundStatsServerState, +} from './main/runtime/stats-daemon'; +import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command'; +import { createInvokeStatsWordHelperHandler, type StatsWordHelperResponse } from './stats-word-helper-client'; + +const logger = createLogger('stats-daemon'); +const STATS_WORD_HELPER_RESPONSE_TIMEOUT_MS = 20_000; + +function readFlagValue(argv: string[], flag: string): string | undefined { + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg) continue; + if (arg === flag) { + const value = argv[i + 1]; + if (value && !value.startsWith('--')) { + return value; + } + return undefined; + } + if (arg.startsWith(`${flag}=`)) { + return arg.split('=', 2)[1]; + } + } + return undefined; +} + +async function waitForWordHelperResponse(responsePath: string): Promise { + const deadline = Date.now() + STATS_WORD_HELPER_RESPONSE_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + if (fs.existsSync(responsePath)) { + return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsWordHelperResponse; + } + } catch { + // retry until timeout + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + return { + ok: false, + error: 'Timed out waiting for stats word helper response.', + }; +} + +const invokeStatsWordHelper = createInvokeStatsWordHelperHandler({ + createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), + joinPath: (...parts) => path.join(...parts), + spawnHelper: async (options) => { + const childArgs = [ + options.scriptPath, + '--stats-word-helper-response-path', + options.responsePath, + '--stats-word-helper-user-data-path', + options.userDataPath, + '--stats-word-helper-word', + options.word, + ]; + const logLevel = readFlagValue(process.argv, '--log-level'); + if (logLevel) { + childArgs.push('--log-level', logLevel); + } + const child = spawn(process.execPath, childArgs, { + stdio: 'ignore', + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: undefined, + }, + }); + return await new Promise((resolve) => { + child.once('exit', (code) => resolve(code ?? 1)); + child.once('error', () => resolve(1)); + }); + }, + waitForResponse: waitForWordHelperResponse, + removeDir: (targetPath) => { + fs.rmSync(targetPath, { recursive: true, force: true }); + }, +}); + +const userDataPath = readFlagValue(process.argv, '--stats-user-data-path')?.trim(); +const responsePath = readFlagValue(process.argv, '--stats-response-path')?.trim(); +const logLevel = readFlagValue(process.argv, '--log-level'); + +if (logLevel) { + setLogLevel(logLevel, 'cli'); +} + +if (!userDataPath) { + if (responsePath) { + writeStatsCliCommandResponse(responsePath, { + ok: false, + error: 'Missing --stats-user-data-path for stats daemon runner.', + }); + } + process.exit(1); +} + +const daemonUserDataPath = userDataPath; + +const statePath = path.join(userDataPath, 'stats-daemon.json'); +const knownWordCachePath = path.join(userDataPath, 'known-words-cache.json'); +const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); +const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js'); + +let tracker: ImmersionTrackerService | null = null; +let statsServer: ReturnType | null = null; + +function writeFailureResponse(message: string): void { + if (!responsePath) return; + writeStatsCliCommandResponse(responsePath, { ok: false, error: message }); +} + +function clearOwnedState(): void { + const rawState = (() => { + try { + return JSON.parse(fs.readFileSync(statePath, 'utf8')) as { pid?: number }; + } catch { + return null; + } + })(); + if (rawState?.pid === process.pid) { + removeBackgroundStatsServerState(statePath); + } +} + +function shutdown(code = 0): void { + try { + statsServer?.close(); + } catch { + // ignore + } + statsServer = null; + try { + tracker?.destroy(); + } catch { + // ignore + } + tracker = null; + clearOwnedState(); + process.exit(code); +} + +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); + +async function main(): Promise { + try { + const configService = new ConfigService(daemonUserDataPath); + const config = configService.getConfig(); + if (config.immersionTracking?.enabled === false) { + throw new Error('Immersion tracking is disabled in config.'); + } + + const configuredDbPath = config.immersionTracking?.dbPath?.trim() || ''; + tracker = new ImmersionTrackerService({ + dbPath: configuredDbPath || path.join(daemonUserDataPath, 'immersion.sqlite'), + policy: { + batchSize: config.immersionTracking.batchSize, + flushIntervalMs: config.immersionTracking.flushIntervalMs, + queueCap: config.immersionTracking.queueCap, + payloadCapBytes: config.immersionTracking.payloadCapBytes, + maintenanceIntervalMs: config.immersionTracking.maintenanceIntervalMs, + retention: { + eventsDays: config.immersionTracking.retention.eventsDays, + telemetryDays: config.immersionTracking.retention.telemetryDays, + sessionsDays: config.immersionTracking.retention.sessionsDays, + dailyRollupsDays: config.immersionTracking.retention.dailyRollupsDays, + monthlyRollupsDays: config.immersionTracking.retention.monthlyRollupsDays, + vacuumIntervalDays: config.immersionTracking.retention.vacuumIntervalDays, + }, + }, + }); + tracker.setCoverArtFetcher( + createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')), + ); + + statsServer = startStatsServer({ + port: config.stats.serverPort, + staticDir: statsDistPath, + tracker, + knownWordCachePath, + ankiConnectConfig: config.ankiConnect, + addYomitanNote: async (word: string) => + await invokeStatsWordHelper({ + helperScriptPath: wordHelperScriptPath, + userDataPath: daemonUserDataPath, + word, + }), + }); + + writeBackgroundStatsServerState(statePath, { + pid: process.pid, + port: config.stats.serverPort, + startedAtMs: Date.now(), + }); + + if (responsePath) { + writeStatsCliCommandResponse(responsePath, { + ok: true, + url: `http://127.0.0.1:${config.stats.serverPort}`, + }); + } + logger.info(`Background stats daemon listening on http://127.0.0.1:${config.stats.serverPort}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('Failed to start stats daemon', message); + writeFailureResponse(message); + shutdown(1); + } +} + +void main(); diff --git a/src/stats-word-helper-client.test.ts b/src/stats-word-helper-client.test.ts new file mode 100644 index 0000000..6cb0e48 --- /dev/null +++ b/src/stats-word-helper-client.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createInvokeStatsWordHelperHandler } from './stats-word-helper-client'; + +test('word helper client returns note id when helper responds before exit', async () => { + const calls: string[] = []; + const handler = createInvokeStatsWordHelperHandler({ + createTempDir: () => '/tmp/stats-word-helper', + joinPath: (...parts) => parts.join('/'), + spawnHelper: async (options) => { + calls.push( + `spawnHelper:${options.scriptPath}:${options.responsePath}:${options.userDataPath}:${options.word}`, + ); + return new Promise((resolve) => setTimeout(() => resolve(0), 20)); + }, + waitForResponse: async (responsePath) => { + calls.push(`waitForResponse:${responsePath}`); + return { ok: true, noteId: 123 }; + }, + removeDir: (targetPath) => { + calls.push(`removeDir:${targetPath}`); + }, + }); + + const noteId = await handler({ + helperScriptPath: '/tmp/stats-word-helper.js', + userDataPath: '/tmp/SubMiner', + word: '猫', + }); + + assert.equal(noteId, 123); + assert.deepEqual(calls, [ + 'spawnHelper:/tmp/stats-word-helper.js:/tmp/stats-word-helper/response.json:/tmp/SubMiner:猫', + 'waitForResponse:/tmp/stats-word-helper/response.json', + 'removeDir:/tmp/stats-word-helper', + ]); +}); + +test('word helper client throws helper response errors', async () => { + const handler = createInvokeStatsWordHelperHandler({ + createTempDir: () => '/tmp/stats-word-helper', + joinPath: (...parts) => parts.join('/'), + spawnHelper: async () => 0, + waitForResponse: async () => ({ ok: false, error: 'helper failed' }), + removeDir: () => {}, + }); + + await assert.rejects( + async () => + handler({ + helperScriptPath: '/tmp/stats-word-helper.js', + userDataPath: '/tmp/SubMiner', + word: '猫', + }), + /helper failed/, + ); +}); diff --git a/src/stats-word-helper-client.ts b/src/stats-word-helper-client.ts new file mode 100644 index 0000000..ab71425 --- /dev/null +++ b/src/stats-word-helper-client.ts @@ -0,0 +1,62 @@ +export type StatsWordHelperResponse = { + ok: boolean; + noteId?: number; + error?: string; +}; + +export function createInvokeStatsWordHelperHandler(deps: { + createTempDir: (prefix: string) => string; + joinPath: (...parts: string[]) => string; + spawnHelper: (options: { + scriptPath: string; + responsePath: string; + userDataPath: string; + word: string; + }) => Promise; + waitForResponse: (responsePath: string) => Promise; + removeDir: (targetPath: string) => void; +}) { + return async (options: { + helperScriptPath: string; + userDataPath: string; + word: string; + }): Promise => { + const tempDir = deps.createTempDir('subminer-stats-word-helper-'); + const responsePath = deps.joinPath(tempDir, 'response.json'); + + try { + const helperExitPromise = deps.spawnHelper({ + scriptPath: options.helperScriptPath, + responsePath, + userDataPath: options.userDataPath, + word: options.word, + }); + + const startupResult = await Promise.race([ + deps.waitForResponse(responsePath).then((response) => ({ kind: 'response' as const, response })), + helperExitPromise.then((status) => ({ kind: 'exit' as const, status })), + ]); + + let response: StatsWordHelperResponse; + if (startupResult.kind === 'response') { + response = startupResult.response; + } else { + if (startupResult.status !== 0) { + throw new Error(`Stats word helper exited before response (status ${startupResult.status}).`); + } + response = await deps.waitForResponse(responsePath); + } + + const exitStatus = await helperExitPromise; + if (exitStatus !== 0) { + throw new Error(`Stats word helper exited with status ${exitStatus}.`); + } + if (!response.ok || typeof response.noteId !== 'number') { + throw new Error(response.error || 'Stats word helper failed.'); + } + return response.noteId; + } finally { + deps.removeDir(tempDir); + } + }; +} diff --git a/src/stats-word-helper.ts b/src/stats-word-helper.ts new file mode 100644 index 0000000..d1e9a5b --- /dev/null +++ b/src/stats-word-helper.ts @@ -0,0 +1,193 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { app, protocol } from 'electron'; +import type { BrowserWindow, Extension, Session } from 'electron'; +import { ConfigService } from './config/service'; +import { createLogger, setLogLevel } from './logger'; +import { loadYomitanExtension } from './core/services/yomitan-extension-loader'; +import { + addYomitanNoteViaSearch, + syncYomitanDefaultAnkiServer, +} from './core/services/tokenizer/yomitan-parser-runtime'; +import type { StatsWordHelperResponse } from './stats-word-helper-client'; +import { clearYomitanExtensionRuntimeState } from './core/services/yomitan-extension-runtime-state'; + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'chrome-extension', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + bypassCSP: true, + }, + }, +]); + +const logger = createLogger('stats-word-helper'); + +function readFlagValue(argv: string[], flag: string): string | undefined { + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg) continue; + if (arg === flag) { + const value = argv[i + 1]; + if (value && !value.startsWith('--')) { + return value; + } + return undefined; + } + if (arg.startsWith(`${flag}=`)) { + return arg.split('=', 2)[1]; + } + } + return undefined; +} + +function writeResponse(responsePath: string | undefined, payload: StatsWordHelperResponse): void { + if (!responsePath) return; + fs.mkdirSync(path.dirname(responsePath), { recursive: true }); + fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf8'); +} + +const responsePath = readFlagValue(process.argv, '--stats-word-helper-response-path')?.trim(); +const userDataPath = readFlagValue(process.argv, '--stats-word-helper-user-data-path')?.trim(); +const word = readFlagValue(process.argv, '--stats-word-helper-word'); +const logLevel = readFlagValue(process.argv, '--log-level'); + +if (logLevel) { + setLogLevel(logLevel, 'cli'); +} + +if (!userDataPath || !word) { + writeResponse(responsePath, { + ok: false, + error: 'Missing stats word helper arguments.', + }); + app.exit(1); +} + +app.setName('SubMiner'); +app.setPath('userData', userDataPath!); + +let yomitanExt: Extension | null = null; +let yomitanSession: Session | null = null; +let yomitanParserWindow: BrowserWindow | null = null; +let yomitanParserReadyPromise: Promise | null = null; +let yomitanParserInitPromise: Promise | null = null; + +function cleanup(): void { + clearYomitanExtensionRuntimeState({ + getYomitanParserWindow: () => yomitanParserWindow, + setYomitanParserWindow: () => { + yomitanParserWindow = null; + }, + setYomitanParserReadyPromise: () => { + yomitanParserReadyPromise = null; + }, + setYomitanParserInitPromise: () => { + yomitanParserInitPromise = null; + }, + setYomitanExtension: () => { + yomitanExt = null; + }, + setYomitanSession: () => { + yomitanSession = null; + }, + }); +} + +async function main(): Promise { + try { + const configService = new ConfigService(userDataPath!); + const config = configService.getConfig(); + const extension = await loadYomitanExtension({ + userDataPath: userDataPath!, + getYomitanParserWindow: () => yomitanParserWindow, + setYomitanParserWindow: (window) => { + yomitanParserWindow = window; + }, + setYomitanParserReadyPromise: (promise) => { + yomitanParserReadyPromise = promise; + }, + setYomitanParserInitPromise: (promise) => { + yomitanParserInitPromise = promise; + }, + setYomitanExtension: (extensionValue) => { + yomitanExt = extensionValue; + }, + setYomitanSession: (sessionValue) => { + yomitanSession = sessionValue; + }, + }); + if (!extension) { + throw new Error('Yomitan extension failed to load.'); + } + + await syncYomitanDefaultAnkiServer( + config.ankiConnect?.url || 'http://127.0.0.1:8765', + { + getYomitanExt: () => yomitanExt, + getYomitanSession: () => yomitanSession, + getYomitanParserWindow: () => yomitanParserWindow, + setYomitanParserWindow: (window) => { + yomitanParserWindow = window; + }, + getYomitanParserReadyPromise: () => yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + yomitanParserInitPromise = promise; + }, + }, + logger, + { forceOverride: true }, + ); + + const noteId = await addYomitanNoteViaSearch( + word!, + { + getYomitanExt: () => yomitanExt, + getYomitanSession: () => yomitanSession, + getYomitanParserWindow: () => yomitanParserWindow, + setYomitanParserWindow: (window) => { + yomitanParserWindow = window; + }, + getYomitanParserReadyPromise: () => yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + yomitanParserInitPromise = promise; + }, + }, + logger, + ); + + if (typeof noteId !== 'number') { + throw new Error('Yomitan failed to create note.'); + } + + writeResponse(responsePath, { + ok: true, + noteId, + }); + cleanup(); + app.exit(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('Stats word helper failed', message); + writeResponse(responsePath, { + ok: false, + error: message, + }); + cleanup(); + app.exit(1); + } +} + +void app.whenReady().then(() => main());