fix(anki): regenerate sentence furigana from the final sentence (#268)

This commit is contained in:
2026-09-22 21:43:19 -07:00
committed by GitHub
parent b0fb39a8f5
commit 7c161723cd
16 changed files with 489 additions and 18 deletions
@@ -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: ' 猫[ねこ]' } } },
);
});
});
+4
View File
@@ -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,
});
+23 -14
View File
@@ -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);
}
}