mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
fix(anki): regenerate sentence furigana from the final sentence (#268)
This commit is contained in:
@@ -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<string | null>)
|
||||
| null = null;
|
||||
private mediaTimingReviewCallback:
|
||||
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
|
||||
| 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<MediaTimingReviewDecision>) | null,
|
||||
): void {
|
||||
|
||||
@@ -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: '<span class="term">tokugi</span>' },
|
||||
},
|
||||
},
|
||||
@@ -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: '<span class="term"><b>tokugi</b></span>',
|
||||
});
|
||||
});
|
||||
@@ -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: '<b>猫</b>を見た。' },
|
||||
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' ? '<b> 猫[ねこ]</b>を 見[み]た。 犬[いぬ]もいた。' : null;
|
||||
};
|
||||
await harness.workflow.execute(42);
|
||||
assert.equal(harness.updates[0]?.fields.Sentence, '猫を見た。犬もいた。');
|
||||
assert.equal(
|
||||
harness.updates[0]?.fields.SentenceFurigana,
|
||||
outcome === 'success' ? '<b> 猫[ねこ]</b>を 見[み]た。 犬[いぬ]もいた。' : '',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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: '<b>猫</b>を見た。' },
|
||||
SentenceFurigana: { value: '<ruby>猫<rt>ねこ</rt></ruby>を見た。' },
|
||||
},
|
||||
},
|
||||
];
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,10 @@ export interface NoteUpdateWorkflowDeps {
|
||||
sentenceFurigana: string,
|
||||
noteFields: Record<string, string>,
|
||||
) => string;
|
||||
generateSentenceFurigana?: (
|
||||
text: string,
|
||||
noteFields: Record<string, string>,
|
||||
) => Promise<string | null>;
|
||||
setCardTypeFields: (
|
||||
updatedFields: Record<string, string>,
|
||||
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,
|
||||
|
||||
@@ -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, '猫'),
|
||||
'<b> 猫[ねこ]</b>を 見[み]た。\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: 'して' },
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
'行儀',
|
||||
),
|
||||
'<b> 行儀[ぎょうぎ]</b>を 直[なお]して',
|
||||
);
|
||||
});
|
||||
@@ -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, '[')
|
||||
.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 ? '<b>' : '</b>';
|
||||
bold = highlighted;
|
||||
const escaped = escapeText(surface);
|
||||
output +=
|
||||
reading && reading !== surface && /[\p{Script=Han}々]/u.test(surface)
|
||||
? ` ${escaped}[${escapeText(reading)}]`
|
||||
: escaped;
|
||||
}
|
||||
return output + (bold ? '</b>' : '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -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' ? '<b> 猫[ねこ]</b>を 見[み]た。' : 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, '<b>猫</b>を見た。');
|
||||
assert.equal(
|
||||
fields?.sentencefurigana,
|
||||
outcome === 'no-field'
|
||||
? undefined
|
||||
: outcome === 'success'
|
||||
? '<b> 猫[ねこ]</b>を 見[み]た。'
|
||||
: '',
|
||||
);
|
||||
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<string | undefined> = [];
|
||||
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: ' 猫[ねこ]' } } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number | null>;
|
||||
generateSentenceFurigana?: StatsMiningRouteOptions['generateSentenceFurigana'];
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||
}
|
||||
@@ -155,6 +157,7 @@ export function createStatsApp(
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
tmdbClient?: TmdbClient;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
generateSentenceFurigana?: StatsMiningRouteOptions['generateSentenceFurigana'];
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | 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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -39,6 +39,7 @@ export type StatsMiningRouteOptions = {
|
||||
input: RetimedSecondarySubtitleInput,
|
||||
) => Promise<string> | string;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
generateSentenceFurigana?: (text: string, highlightedText?: string) => Promise<string | null>;
|
||||
createMediaGenerator?: () => StatsServerMediaGenerator;
|
||||
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
|
||||
nowMs?: () => number;
|
||||
|
||||
@@ -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 }),
|
||||
'<b> 猫[ねこ]</b>がいる。',
|
||||
);
|
||||
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<never>(() => {}));
|
||||
assert.equal(
|
||||
await generateSentenceFurigana('猫', undefined, deps, {
|
||||
error: () => undefined,
|
||||
warn: (message) => warnings.push(message),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(warnings.length, 1);
|
||||
},
|
||||
);
|
||||
@@ -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<typeof requestYomitanParseResults>[1],
|
||||
logger: Parameters<typeof requestYomitanParseResults>[2],
|
||||
): Promise<string | null> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const results = await Promise.race([
|
||||
requestYomitanParseResults(text, deps, logger),
|
||||
new Promise<never>((_, 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);
|
||||
}
|
||||
}
|
||||
+10
@@ -15,6 +15,7 @@
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<string | null> {
|
||||
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) =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user