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

This commit is contained in:
sudacode authored and GitHub committed 2026-09-22 21:43:19 -07:00
1 parent b0fb39a8f5
commit 7c161723cd
16 files changed
+489 -18

No files matched your search

@@ -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);
});
+25 -1
View File
@@ -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: ']' }],
],
},
]),
'&lt; 猫[ねこ]&gt; &#91;メモ&#93;',
);
});
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>を 直[なお]して',
);
});
+87
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;');
}
// 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;
}