diff --git a/changes/sentence-furigana.md b/changes/sentence-furigana.md new file mode 100644 index 00000000..94c01506 --- /dev/null +++ b/changes/sentence-furigana.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Keep word-card sentence furigana in sync with full stats-search context and expanded timing-review selections. Clear stale readings if regeneration fails. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index d6812179..11c8747c 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -212,6 +212,8 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde **Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation. +When word-card enrichment changes the sentence context, including an expanded timing-review selection, SubMiner regenerates `SentenceFurigana` from the final sentence. Unchanged sentences keep their existing furigana formatting. If generation fails, SubMiner clears stale furigana so compatible templates can fall back to `Sentence`. + Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session. If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index de40a036..8988251b 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -152,7 +152,7 @@ Stats server config lives under `stats`: The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence: -- **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 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, full-sentence readings in `SentenceFurigana` when that field exists, 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 and image from the source video. - **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip. diff --git a/src/anki-integration.ts b/src/anki-integration.ts index eb1a3bd3..495539b2 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -243,6 +243,9 @@ export class AnkiIntegration { private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null; private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; + private generateSentenceFuriganaCallback: + | ((text: string, highlightedText?: string) => Promise) + | null = null; private mediaTimingReviewCallback: | ((request: MediaTimingReviewRequest) => Promise) | null = null; @@ -666,6 +669,13 @@ export class AnkiIntegration { processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields), processSentenceFurigana: (sentenceFurigana, noteFields) => this.processSentenceFurigana(sentenceFurigana, noteFields), + generateSentenceFurigana: async (text, noteFields) => + this.generateSentenceFuriganaCallback?.( + text, + this.config.behavior?.highlightWord === false + ? undefined + : this.getSentenceHighlightText(noteFields), + ) ?? null, setCardTypeFields: (updatedFields, availableFieldNames, cardKind) => this.setCardTypeFields(updatedFields, availableFieldNames, cardKind), resolveConfiguredFieldName: (noteInfo, ...preferredNames) => @@ -1783,6 +1793,10 @@ export class AnkiIntegration { this.consumeSubtitleMiningContextCallback = callback; } + setSentenceFuriganaGenerator(callback: typeof this.generateSentenceFuriganaCallback): void { + this.generateSentenceFuriganaCallback = callback; + } + setMediaTimingReviewCallback( callback: ((request: MediaTimingReviewRequest) => Promise) | null, ): void { diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 99019886..d1dcb188 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -166,13 +166,14 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => { const harness = createWorkflowHarness(); + harness.deps.getCurrentSubtitleText = () => 'tokugi'; harness.deps.client.notesInfo = async () => [ { noteId: 42, fields: { Expression: { value: 'tokugi' }, - Sentence: { value: '' }, + Sentence: { value: 'tokugi' }, SentenceFurigana: { value: 'tokugi' }, }, }, @@ -184,7 +185,7 @@ test('NoteUpdateWorkflow updates sentence furigana when highlight processor chan assert.equal(harness.updates.length, 1); assert.deepEqual(harness.updates[0]?.fields, { - Sentence: 'subtitle-text', + Sentence: 'tokugi', SentenceFurigana: 'tokugi', }); }); @@ -776,3 +777,63 @@ test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']); assert.ok(harness.warnings.length === 0); }); + +for (const outcome of ['success', 'unavailable', 'throws'] as const) { + test(`NoteUpdateWorkflow regenerates expanded furigana (${outcome})`, async () => { + const harness = createWorkflowHarness(); + harness.deps.client.notesInfo = async () => [ + { + noteId: 42, + fields: { + Expression: { value: '猫' }, + Sentence: { value: 'を見た。' }, + SentenceFurigana: { value: ' 猫[ねこ]を 見[み]た。' }, + }, + }, + ]; + harness.deps.captureSubtitleMediaContext = () => ({ + source: 'overlay', + text: '猫を見た。', + startTime: 4, + endTime: 6, + }); + harness.deps.reviewMediaTiming = async () => ({ + action: 'confirm', + text: '猫を見た。犬もいた。', + startTime: 2, + endTime: 8, + }); + harness.deps.generateSentenceFurigana = async (text, fields) => { + assert.equal(text, '猫を見た。犬もいた。'); + assert.equal(fields.expression, '猫'); + if (outcome === 'throws') throw new Error('parser unavailable'); + return outcome === 'success' ? ' 猫[ねこ]を 見[み]た。 犬[いぬ]もいた。' : null; + }; + await harness.workflow.execute(42); + assert.equal(harness.updates[0]?.fields.Sentence, '猫を見た。犬もいた。'); + assert.equal( + harness.updates[0]?.fields.SentenceFurigana, + outcome === 'success' ? ' 猫[ねこ]を 見[み]た。 犬[いぬ]もいた。' : '', + ); + }); +} + +test('NoteUpdateWorkflow preserves native furigana formatting when sentence context is unchanged', async () => { + const harness = createWorkflowHarness(); + harness.deps.client.notesInfo = async () => [ + { + noteId: 42, + fields: { + Expression: { value: '猫' }, + Sentence: { value: 'を見た。' }, + SentenceFurigana: { value: 'ねこを見た。' }, + }, + }, + ]; + harness.deps.getCurrentSubtitleText = () => '猫を見た。'; + harness.deps.generateSentenceFurigana = async () => { + assert.fail('unchanged sentence must keep native formatting'); + }; + await harness.workflow.execute(42); + assert.equal(harness.updates[0]?.fields.SentenceFurigana, undefined); +}); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index 7d1468b1..ccb560c8 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -74,6 +74,10 @@ export interface NoteUpdateWorkflowDeps { sentenceFurigana: string, noteFields: Record, ) => string; + generateSentenceFurigana?: ( + text: string, + noteFields: Record, + ) => Promise; setCardTypeFields: ( updatedFields: Record, availableFieldNames: string[], @@ -282,7 +286,27 @@ export class NoteUpdateWorkflow { const existingSentenceFurigana = sentenceFuriganaField ? noteInfo.fields[sentenceFuriganaField]?.value || '' : ''; - if (sentenceFuriganaField && existingSentenceFurigana && this.deps.processSentenceFurigana) { + const sentenceChanged = + sentenceField && + currentSubtitleText && + normalizeSubtitleContextText(currentSubtitleText) !== + normalizeSubtitleContextText(noteInfo.fields[sentenceField]?.value ?? ''); + if (sentenceFuriganaField && sentenceChanged) { + let furigana: string | null = null; + try { + furigana = + (await this.deps.generateSentenceFurigana?.(currentSubtitleText, fields)) ?? null; + } catch (error) { + this.deps.logWarn('Failed to regenerate sentence furigana:', error); + } + // Empty furigana lets card templates fall back to the updated Sentence field. + updatedFields[sentenceFuriganaField] = furigana ?? ''; + updatePerformed = true; + } else if ( + sentenceFuriganaField && + existingSentenceFurigana && + this.deps.processSentenceFurigana + ) { const processedSentenceFurigana = this.deps.processSentenceFurigana( existingSentenceFurigana, fields, diff --git a/src/anki-integration/sentence-furigana.test.ts b/src/anki-integration/sentence-furigana.test.ts new file mode 100644 index 00000000..7654c0c9 --- /dev/null +++ b/src/anki-integration/sentence-furigana.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { formatSentenceFurigana } from './sentence-furigana'; + +const parsed = [ + { + source: 'scanning-parser', + content: [ + [{ text: '猫', reading: 'ねこ' }], + [{ text: 'を' }], + [{ text: '見', reading: 'み' }, { text: 'た' }], + [{ text: '。\n' }], + [{ text: '犬', reading: 'いぬ' }], + [{ text: 'もいた。' }], + ], + }, +]; + +test('formats the complete expanded sentence with readings and the mined word highlighted', () => { + assert.equal( + formatSentenceFurigana('猫を見た。\n犬もいた。', parsed, '猫'), + ' 猫[ねこ]を 見[み]た。\n 犬[いぬ]もいた。', + ); +}); + +test('rejects headword-only and malformed parse results instead of saving partial furigana', () => { + assert.equal( + formatSentenceFurigana('猫を見た。', [ + { source: 'scanning-parser', content: [[{ text: '猫', reading: 'ねこ' }]] }, + ]), + null, + ); + assert.equal( + formatSentenceFurigana('猫', [ + { source: 'scanning-parser', content: [[{ text: '猫', reading: 42 }]] }, + ]), + null, + ); +}); + +test('escapes literal markup and annotation delimiters and leaves kana unannotated', () => { + const text = '<猫> [メモ]'; + assert.equal( + formatSentenceFurigana(text, [ + { + source: 'scanning-parser', + content: [ + [{ text: '<' }], + [{ text: '猫', reading: 'ねこ' }], + [{ text: '> [' }], + [{ text: 'メモ', reading: 'めも' }], + [{ text: ']' }], + ], + }, + ]), + '< 猫[ねこ]> [メモ]', + ); +}); + +test('highlights the mined word without bolding the rest of a dictionary phrase', () => { + assert.equal( + formatSentenceFurigana( + '行儀を直して', + [ + { + content: [ + [ + { text: '行儀', reading: 'ぎょうぎ' }, + { text: 'を' }, + { text: '直', reading: 'なお' }, + { text: 'して' }, + ], + ], + }, + ], + '行儀', + ), + ' 行儀[ぎょうぎ]を 直[なお]して', + ); +}); diff --git a/src/anki-integration/sentence-furigana.ts b/src/anki-integration/sentence-furigana.ts new file mode 100644 index 00000000..e707c606 --- /dev/null +++ b/src/anki-integration/sentence-furigana.ts @@ -0,0 +1,87 @@ +type Segment = { text: string; reading?: string }; + +function readGroups(value: unknown): Segment[][] | null { + if (!Array.isArray(value)) return null; + const groups: Segment[][] = []; + const rawGroups: unknown[] = value; + for (const group of rawGroups) { + if (!Array.isArray(group)) return null; + const segments: Segment[] = []; + const rawSegments: unknown[] = group; + for (const segment of rawSegments) { + if ( + typeof segment !== 'object' || + segment === null || + !('text' in segment) || + typeof segment.text !== 'string' || + ('reading' in segment && + segment.reading !== undefined && + typeof segment.reading !== 'string') + ) + return null; + segments.push({ + text: segment.text, + reading: + 'reading' in segment && typeof segment.reading === 'string' ? segment.reading : undefined, + }); + } + groups.push(segments); + } + return groups; +} + +function escapeText(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\[/g, '[') + .replace(/\]/g, ']'); +} + +// Only accept a complete parse, so a lookup of just the headword cannot replace sentence context. +export function formatSentenceFurigana( + text: string, + results: unknown[] | null, + highlightedText?: string, +): string | null { + for (const result of results ?? []) { + if (typeof result !== 'object' || result === null || !('content' in result)) continue; + const groups = readGroups(result.content); + if ( + !groups || + groups + .flat() + .map((segment) => segment.text) + .join('') !== text + ) + continue; + const highlights: number[] = []; + if (highlightedText) { + let start = text.indexOf(highlightedText); + while (start >= 0) { + highlights.push(start); + start = text.indexOf(highlightedText, start + highlightedText.length); + } + } + let offset = 0; + let bold = false; + let output = ''; + for (const { text: surface, reading } of groups.flat()) { + const start = offset; + offset += surface.length; + const highlighted = highlights.some( + (position) => position < offset && position + (highlightedText?.length ?? 0) > start, + ); + if (highlighted !== bold) output += highlighted ? '' : ''; + bold = highlighted; + const escaped = escapeText(surface); + output += + reading && reading !== surface && /[\p{Script=Han}々]/u.test(surface) + ? ` ${escaped}[${escapeText(reading)}]` + : escaped; + } + return output + (bold ? '' : ''); + } + return null; +} diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index eefc9aff..7e3bf1ed 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -4321,3 +4321,101 @@ it('TMDB reassignment returns 404 for a missing library entry before fetching de assert.equal(fetches, 1); assert.deepEqual(assignments, [1]); }); + +for (const outcome of ['success', 'unavailable', 'throws', 'no-field'] as const) { + it(`stats word mining updates full sentence furigana without media (${outcome})`, async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + await withFakeAnkiConnect( + async (requests, url) => { + let calls = 0; + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: { + url, + deck: 'Mining', + media: { generateAudio: false, generateImage: false }, + }, + addYomitanNote: async () => 12345, + generateSentenceFurigana: async (text, word) => { + calls++; + assert.equal(text, '猫を見た。'); + assert.equal(word, '猫'); + if (outcome === 'throws') throw new Error('parser unavailable'); + return outcome === 'success' ? ' 猫[ねこ]を 見[み]た。' : null; + }, + }); + const response = await app.request('/api/stats/mine-card?mode=word', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 1000, + endMs: 2000, + sentence: '猫を見た。', + word: '猫', + }), + }); + assert.equal(response.status, 200); + const fields = requests.find((request) => request.action === 'updateNoteFields')?.params + ?.note?.fields; + assert.equal(fields?.Sentence, 'を見た。'); + assert.equal( + fields?.sentencefurigana, + outcome === 'no-field' + ? undefined + : outcome === 'success' + ? ' 猫[ねこ]を 見[み]た。' + : '', + ); + assert.equal(calls, outcome === 'no-field' ? 0 : 1); + }, + { + notesInfoFields: { + Sentence: { value: '猫' }, + ...(outcome === 'no-field' ? {} : { sentencefurigana: { value: ' 猫[ねこ]' } }), + }, + }, + ); + }); + }); +} + +it('stats word mining skips furigana highlighting when highlightWord is disabled', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + await withFakeAnkiConnect( + async (_requests, url) => { + const highlights: Array = []; + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: { + url, + deck: 'Mining', + media: { generateAudio: false, generateImage: false }, + behavior: { highlightWord: false }, + }, + addYomitanNote: async () => 12345, + generateSentenceFurigana: async (_text, highlightedText) => { + highlights.push(highlightedText); + return ' 猫[ねこ]を 見[み]た。'; + }, + }); + const response = await app.request('/api/stats/mine-card?mode=word', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 1000, + endMs: 2000, + sentence: '猫を見た。', + word: '猫', + }), + }); + assert.equal(response.status, 200); + assert.deepEqual(highlights, [undefined]); + }, + { notesInfoFields: { Sentence: { value: '猫' }, SentenceFurigana: { value: ' 猫[ねこ]' } } }, + ); + }); +}); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 5d3c05d3..66c60204 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -1,3 +1,4 @@ +import type { StatsMiningRouteOptions } from './stats-server/mining-support'; import { Hono } from 'hono'; import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; @@ -132,6 +133,7 @@ export interface StatsServerConfig { anilistRateLimiter?: AnilistRateLimiter; tmdbClient?: TmdbClient; addYomitanNote?: (word: string) => Promise; + generateSentenceFurigana?: StatsMiningRouteOptions['generateSentenceFurigana']; resolveAnkiNoteId?: (noteId: number) => number; resolveSentenceSearchHeadwords?: (term: string) => Promise | string[]; } @@ -155,6 +157,7 @@ export function createStatsApp( anilistRateLimiter?: AnilistRateLimiter; tmdbClient?: TmdbClient; addYomitanNote?: (word: string) => Promise; + generateSentenceFurigana?: StatsMiningRouteOptions['generateSentenceFurigana']; resolveAnkiNoteId?: (noteId: number) => number; resolveSentenceSearchHeadwords?: (term: string) => Promise | string[]; createMediaGenerator?: () => StatsServerMediaGenerator; @@ -191,6 +194,7 @@ export async function startStatsServerWithRuntime( anilistRateLimiter: config.anilistRateLimiter, tmdbClient: config.tmdbClient, addYomitanNote: config.addYomitanNote, + generateSentenceFurigana: config.generateSentenceFurigana, resolveAnkiNoteId: config.resolveAnkiNoteId, resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords, }); diff --git a/src/core/services/stats-server/mining-routes.ts b/src/core/services/stats-server/mining-routes.ts index a2e2d7ba..ab31119f 100644 --- a/src/core/services/stats-server/mining-routes.ts +++ b/src/core/services/stats-server/mining-routes.ts @@ -17,7 +17,6 @@ import { getStatsDirectMiningAudioFieldNames, getStatsWordMiningAudioFieldName, resolveStatsNoteFieldName, - shouldUseStatsLapisKikuCardFields, statsMiningLogger, type StatsMiningRouteOptions, type StatsServerNoteInfo, @@ -228,19 +227,11 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; let noteInfo: StatsServerNoteInfo | null = null; - if ( - audioBuffer || - (syncAnimatedImageToWordAudio && generateImage) || - shouldUseStatsLapisKikuCardFields(ankiConfig) - ) { - try { - const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; - noteInfo = noteInfoResult[0] ?? null; - } catch (err) { - if (syncAnimatedImageToWordAudio && generateImage) { - errors.push(`image: ${(err as Error).message}`); - } - } + try { + const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; + noteInfo = noteInfoResult[0] ?? null; + } catch (error) { + errors.push(`note fields: ${error instanceof Error ? error.message : String(error)}`); } if (syncAnimatedImageToWordAudio && generateImage) { try { @@ -272,6 +263,24 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; mediaFields[sentenceFieldName] = highlightedSentence; + const furiganaFieldName = noteInfo + ? resolveStatsNoteFieldName(noteInfo, 'SentenceFurigana') + : null; + if (furiganaFieldName) { + let furigana: string | null = null; + try { + furigana = + (await options?.generateSentenceFurigana?.( + sentence, + ankiConfig.behavior?.highlightWord === false ? undefined : word, + )) ?? null; + } catch (error) { + statsMiningLogger.warn('Failed to generate sentence furigana:', error); + } + mediaFields[furiganaFieldName] = furigana ?? ''; + if (furigana === null) + errors.push('furigana: unavailable; using the full sentence without readings'); + } applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig); if (audioBuffer) { diff --git a/src/core/services/stats-server/mining-support.ts b/src/core/services/stats-server/mining-support.ts index bdb2c9c8..af986ef8 100644 --- a/src/core/services/stats-server/mining-support.ts +++ b/src/core/services/stats-server/mining-support.ts @@ -39,6 +39,7 @@ export type StatsMiningRouteOptions = { input: RetimedSecondarySubtitleInput, ) => Promise | string; addYomitanNote?: (word: string) => Promise; + generateSentenceFurigana?: (text: string, highlightedText?: string) => Promise; createMediaGenerator?: () => StatsServerMediaGenerator; onMiningTiming?: (event: StatsMiningTimingEvent) => void; nowMs?: () => number; diff --git a/src/core/services/tokenizer/sentence-furigana.test.ts b/src/core/services/tokenizer/sentence-furigana.test.ts new file mode 100644 index 00000000..4b75e514 --- /dev/null +++ b/src/core/services/tokenizer/sentence-furigana.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { generateSentenceFurigana } from './sentence-furigana'; +import { createDeps, runInjectedYomitanScript } from './yomitan-scan-test-harness'; + +test('generates sentence readings through the parser runtime bridge', async () => { + const requests: string[] = []; + const deps = createDeps((script) => + runInjectedYomitanScript(script, (action, params) => { + requests.push(action); + if (action === 'optionsGetFull') + return { profileCurrent: 0, profiles: [{ options: { scanning: { length: 20 } } }] }; + assert.equal(action, 'parseText'); + assert.ok(typeof params === 'object' && params !== null && 'text' in params); + assert.equal(params.text, '猫がいる。'); + return [ + { + source: 'scanning-parser', + content: [[{ text: '猫', reading: 'ねこ' }], [{ text: 'がいる。', reading: '' }]], + }, + ]; + }), + ); + assert.equal( + await generateSentenceFurigana('猫がいる。', '猫', deps, { error: assert.fail }), + ' 猫[ねこ]がいる。', + ); + assert.ok(requests.includes('parseText')); +}); + +test( + 'a stalled parser cannot indefinitely block sentence and media updates', + { timeout: 15_000 }, + async () => { + const warnings: string[] = []; + const deps = createDeps(() => new Promise(() => {})); + assert.equal( + await generateSentenceFurigana('猫', undefined, deps, { + error: () => undefined, + warn: (message) => warnings.push(message), + }), + null, + ); + assert.equal(warnings.length, 1); + }, +); diff --git a/src/core/services/tokenizer/sentence-furigana.ts b/src/core/services/tokenizer/sentence-furigana.ts new file mode 100644 index 00000000..9bcf9315 --- /dev/null +++ b/src/core/services/tokenizer/sentence-furigana.ts @@ -0,0 +1,28 @@ +import { formatSentenceFurigana } from '../../../anki-integration/sentence-furigana'; +import { requestYomitanParseResults } from './yomitan-parser-runtime'; + +export async function generateSentenceFurigana( + text: string, + highlightedText: string | undefined, + deps: Parameters[1], + logger: Parameters[2], +): Promise { + let timer: ReturnType | undefined; + try { + const results = await Promise.race([ + requestYomitanParseResults(text, deps, logger), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Sentence furigana generation timed out')), + 10_000, + ); + }), + ]); + return formatSentenceFurigana(text, results, highlightedText); + } catch (error) { + logger.warn?.('Failed to generate sentence furigana:', error); + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/src/main.ts b/src/main.ts index 25151fff..e58ebaf5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +import { generateSentenceFurigana } from './core/services/tokenizer/sentence-furigana'; import { app, BrowserWindow, @@ -5131,6 +5132,13 @@ function createMainWindow(): BrowserWindow { return window; } +function generateMiningSentenceFurigana( + text: string, + highlightedText?: string, +): Promise { + return generateSentenceFurigana(text, highlightedText, getYomitanParserRuntimeDeps(), logger); +} + function initializeOverlayRuntime(): void { initializeOverlayRuntimeHandler(); if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) { @@ -5140,6 +5148,7 @@ function initializeOverlayRuntime(): void { appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations); appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview); + appState.ankiIntegration?.setSentenceFuriganaGenerator(generateMiningSentenceFurigana); syncOverlayMpvSubtitleSuppression(); } @@ -6015,6 +6024,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ appState.ankiIntegration?.setMediaTimingReviewCallback( mediaTimingReviewRuntime.requestReview, ); + appState.ankiIntegration?.setSentenceFuriganaGenerator(generateMiningSentenceFurigana); }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getCachedMediaPath: (currentVideoPath, kind) => diff --git a/src/main/runtime/stats-server-runtime.ts b/src/main/runtime/stats-server-runtime.ts index a1206a69..23378b78 100644 --- a/src/main/runtime/stats-server-runtime.ts +++ b/src/main/runtime/stats-server-runtime.ts @@ -1,3 +1,4 @@ +import { generateSentenceFurigana } from '../../core/services/tokenizer/sentence-furigana'; import path from 'node:path'; import type { BrowserWindow } from 'electron'; import { @@ -166,6 +167,8 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { }), resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId), resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term), + generateSentenceFurigana: (text, highlightedText) => + generateSentenceFurigana(text, highlightedText, yomitanDeps, yomitanLogger), addYomitanNote: async (word: string) => { const ankiConnectConfig = deps.getResolvedConfig().ankiConnect; const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';