fix: Kiku field grouping, frequency particles, sidebar media, Yomitan popup visibility (#91)

This commit is contained in:
2026-05-27 01:40:48 -07:00
committed by GitHub
parent efe50ed1e4
commit 1dcfed86ab
52 changed files with 1695 additions and 368 deletions
@@ -74,13 +74,13 @@ function makeNote(noteId: number, fields: Record<string, string>): FieldGrouping
};
}
test('getGroupableFieldNames includes configured fields without duplicating ExpressionAudio', () => {
test('getGroupableFieldNames includes Kiku context fields and omits word audio fields', () => {
const { collaborator } = createCollaborator({
config: {
fields: {
image: 'Illustration',
sentence: 'SentenceText',
audio: 'ExpressionAudio',
audio: 'CustomWordAudio',
miscInfo: 'ExtraInfo',
},
},
@@ -97,33 +97,84 @@ test('getGroupableFieldNames includes configured fields without duplicating Expr
]);
});
test('computeFieldGroupingMergedFields syncs a custom audio field from merged SentenceAudio', async () => {
const { collaborator } = createCollaborator({
config: {
fields: {
audio: 'CustomAudio',
},
},
});
test('computeFieldGroupingMergedFields groups both notes and sorts by descending group id when keeping original', async () => {
const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
1,
2,
makeNote(1, {
SentenceAudio: '[sound:keep.mp3]',
CustomAudio: '[sound:stale.mp3]',
300,
200,
makeNote(300, {
Sentence: 'original sentence',
SentenceAudio: '[sound:original-a.mp3] [sound:original-b.mp3]',
Picture: '<img src="original.png">',
MiscInfo: 'original misc',
ExpressionAudio: '[sound:word.mp3]',
}),
makeNote(2, {
makeNote(200, {
Sentence: 'new sentence',
SentenceAudio: '[sound:new.mp3]',
Picture: '<img src="new.png">',
MiscInfo: 'new misc',
}),
false,
);
assert.equal(
merged.SentenceAudio,
'<span data-group-id="1">[sound:keep.mp3]</span><span data-group-id="2">[sound:new.mp3]</span>',
merged.Sentence,
'<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
);
assert.equal(
merged.SentenceAudio,
'<span data-group-id="300">[sound:original-a.mp3] [sound:original-b.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
);
assert.equal(
merged.Picture,
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
);
assert.equal(
merged.MiscInfo,
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
);
assert.equal('ExpressionAudio' in merged, false);
});
test('computeFieldGroupingMergedFields sorts original before new when merging original into a newer target', async () => {
const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
200,
300,
makeNote(200, {
Sentence: 'new sentence',
SentenceAudio: '[sound:new.mp3]',
Picture: '<img src="new.png">',
MiscInfo: 'new misc',
}),
makeNote(300, {
Sentence: 'original sentence',
SentenceAudio: '[sound:original.mp3]',
Picture: '<img src="original.png">',
MiscInfo: 'original misc',
}),
false,
);
assert.equal(
merged.Sentence,
'<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
);
assert.equal(
merged.SentenceAudio,
'<span data-group-id="300">[sound:original.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
);
assert.equal(
merged.Picture,
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
);
assert.equal(
merged.MiscInfo,
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
);
assert.equal(merged.CustomAudio, merged.SentenceAudio);
});
test('computeFieldGroupingMergedFields keeps strict fields when source is empty and warns on malformed spans', async () => {
@@ -147,7 +198,7 @@ test('computeFieldGroupingMergedFields keeps strict fields when source is empty
assert.equal(
merged.Sentence,
'<span data-group-id="3"><span data-group-id="abc">keep sentence</span></span><span data-group-id="4">source sentence</span>',
'<span data-group-id="4">source sentence</span><span data-group-id="3"><span data-group-id="abc">keep sentence</span></span>',
);
assert.equal(merged.SentenceAudio, '<span data-group-id="4">[sound:source.mp3]</span>');
assert.equal(warnings.length, 4);
@@ -199,3 +250,21 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
assert.equal(withMedia.Picture, '<img data-group-id="11" src="generated.png">');
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
});
test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => {
const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
SentenceFurigana: 'original furigana',
}),
makeNote(200, {
SentenceFurigana: '',
}),
false,
);
assert.equal(merged.SentenceFurigana, '');
});
+44 -113
View File
@@ -51,9 +51,6 @@ export class FieldGroupingMergeCollaborator {
fields.push('Picture');
if (config.fields?.image) fields.push(config.fields?.image);
if (config.fields?.sentence) fields.push(config.fields?.sentence);
if (config.fields?.audio && config.fields?.audio.toLowerCase() !== 'expressionaudio') {
fields.push(config.fields?.audio);
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceAudioField = sentenceCardConfig.audioField;
if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField);
@@ -94,12 +91,6 @@ export class FieldGroupingMergeCollaborator {
}
}
if (!sourceFields['SentenceFurigana'] && sourceFields['Sentence']) {
sourceFields['SentenceFurigana'] = sourceFields['Sentence'];
}
if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) {
sourceFields['Sentence'] = sourceFields['SentenceFurigana'];
}
if (!sourceFields[configuredWordField] && sourceFields['Expression']) {
sourceFields[configuredWordField] = sourceFields['Expression'];
}
@@ -112,13 +103,6 @@ export class FieldGroupingMergeCollaborator {
if (!sourceFields['Word'] && sourceFields[configuredWordField]) {
sourceFields['Word'] = sourceFields[configuredWordField];
}
if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) {
sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio'];
}
if (!sourceFields['ExpressionAudio'] && sourceFields['SentenceAudio']) {
sourceFields['ExpressionAudio'] = sourceFields['SentenceAudio'];
}
if (
config.fields?.sentence &&
!sourceFields[config.fields?.sentence] &&
@@ -169,6 +153,20 @@ export class FieldGroupingMergeCollaborator {
const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName);
if (!existingValue.trim() && !newValue.trim()) continue;
if (keepFieldNormalized === 'sentencefurigana') {
mergedFields[keepFieldName] =
existingValue.trim() && newValue.trim()
? this.applyFieldGrouping(
existingValue,
newValue,
keepNoteId,
deleteNoteId,
keepFieldName,
)
: '';
continue;
}
if (isStrictField) {
mergedFields[keepFieldName] = this.applyFieldGrouping(
existingValue,
@@ -191,29 +189,6 @@ export class FieldGroupingMergeCollaborator {
}
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const resolvedSentenceAudioField = this.deps.resolveFieldName(
keepFieldNames,
sentenceCardConfig.audioField || 'SentenceAudio',
);
const resolvedExpressionAudioField = this.deps.resolveFieldName(
keepFieldNames,
config.fields?.audio || 'ExpressionAudio',
);
if (
resolvedSentenceAudioField &&
resolvedExpressionAudioField &&
resolvedExpressionAudioField !== resolvedSentenceAudioField
) {
const mergedSentenceAudioValue =
mergedFields[resolvedSentenceAudioField] ||
keepNoteInfo.fields[resolvedSentenceAudioField]?.value ||
'';
if (mergedSentenceAudioValue.trim()) {
mergedFields[resolvedExpressionAudioField] = mergedSentenceAudioValue;
}
}
return mergedFields;
}
@@ -228,22 +203,14 @@ export class FieldGroupingMergeCollaborator {
}
private extractUngroupedValue(value: string): string {
const groupedSpanRegex = /<span\s+data-group-id="[^"]*">[\s\S]*?<\/span>/gi;
const ungrouped = value.replace(groupedSpanRegex, '').trim();
const ungrouped = this.extractUngroupedRemainder(value);
if (ungrouped) return ungrouped;
return value.trim();
}
private extractLastSoundTag(value: string): string {
const matches = value.match(/\[sound:[^\]]+\]/g);
if (!matches || matches.length === 0) return '';
return matches[matches.length - 1]!;
}
private extractLastImageTag(value: string): string {
const matches = value.match(/<img\b[^>]*>/gi);
if (!matches || matches.length === 0) return '';
return matches[matches.length - 1]!;
private extractUngroupedRemainder(value: string): string {
const groupedSpanRegex = /<span\b[^>]*data-group-id="[^"]*"[^>]*>[\s\S]*?<\/span>/gi;
return value.replace(groupedSpanRegex, '').trim();
}
private extractImageTags(value: string): string[] {
@@ -274,7 +241,7 @@ export class FieldGroupingMergeCollaborator {
}
}
const spanRegex = /<span\s+data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi;
const spanRegex = /<span\b[^>]*data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi;
let match;
while ((match = spanRegex.exec(value)) !== null) {
const groupId = Number(match[1]);
@@ -298,25 +265,16 @@ export class FieldGroupingMergeCollaborator {
fieldName: string,
): { groupId: number; content: string }[] {
const entries = this.extractSpanEntries(value, fieldName);
if (entries.length === 0) {
const ungrouped = this.normalizeStrictGroupedValue(
this.extractUngroupedValue(value),
fieldName,
);
if (ungrouped) {
entries.push({ groupId: fallbackGroupId, content: ungrouped });
}
const ungroupedSource =
entries.length > 0
? this.extractUngroupedRemainder(value)
: this.extractUngroupedValue(value);
const ungrouped = this.normalizeStrictGroupedValue(ungroupedSource, fieldName);
if (ungrouped) {
entries.push({ groupId: fallbackGroupId, content: ungrouped });
}
const unique: { groupId: number; content: string }[] = [];
const seen = new Set<string>();
for (const entry of entries) {
const key = entry.content;
if (seen.has(key)) continue;
seen.add(key);
unique.push(entry);
}
return unique;
return entries;
}
private parsePictureEntries(
@@ -351,29 +309,13 @@ export class FieldGroupingMergeCollaborator {
if (!ungrouped) return '';
const normalizedField = fieldName.toLowerCase();
if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') {
const lastSoundTag = this.extractLastSoundTag(ungrouped);
if (!lastSoundTag) {
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
}
return lastSoundTag || ungrouped;
}
if (normalizedField === 'picture') {
const lastImageTag = this.extractLastImageTag(ungrouped);
if (!lastImageTag) {
this.deps.warnFieldParseOnce(fieldName, 'missing-image-tag');
}
return lastImageTag || ungrouped;
if (normalizedField === 'sentenceaudio' && !/\[sound:[^\]]+\]/.test(ungrouped)) {
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
}
return ungrouped;
}
private getPictureDedupKey(tag: string): string {
return tag.replace(/\sdata-group-id="[^"]*"/gi, '').trim();
}
private getStrictSpanGroupingFields(): Set<string> {
const strictFields = new Set(this.strictGroupingFieldDefaults);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
@@ -390,6 +332,16 @@ export class FieldGroupingMergeCollaborator {
return this.getStrictSpanGroupingFields().has(normalized);
}
private isPictureField(fieldName: string): boolean {
const normalized = fieldName.toLowerCase();
const configuredImageField = this.deps.getConfig().fields?.image?.toLowerCase();
return normalized === 'picture' || normalized === configuredImageField;
}
private sortEntriesByGroupIdDescending<T extends { groupId: number }>(entries: T[]): T[] {
return [...entries].sort((a, b) => b.groupId - a.groupId);
}
private applyFieldGrouping(
existingValue: string,
newValue: string,
@@ -398,24 +350,15 @@ export class FieldGroupingMergeCollaborator {
fieldName: string,
): string {
if (this.shouldUseStrictSpanGrouping(fieldName)) {
if (fieldName.toLowerCase() === 'picture') {
if (this.isPictureField(fieldName)) {
const keepEntries = this.parsePictureEntries(existingValue, keepGroupId);
const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId);
if (keepEntries.length === 0 && sourceEntries.length === 0) {
return existingValue || newValue;
}
const mergedTags = keepEntries.map((entry) =>
this.ensureImageGroupId(entry.tag, entry.groupId),
);
const seen = new Set(mergedTags.map((tag) => this.getPictureDedupKey(tag)));
for (const entry of sourceEntries) {
const normalized = this.ensureImageGroupId(entry.tag, entry.groupId);
const dedupKey = this.getPictureDedupKey(normalized);
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
mergedTags.push(normalized);
}
return mergedTags.join('');
return this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries])
.map((entry) => entry.tag)
.join('');
}
const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName);
@@ -423,19 +366,7 @@ export class FieldGroupingMergeCollaborator {
if (keepEntries.length === 0 && sourceEntries.length === 0) {
return existingValue || newValue;
}
if (sourceEntries.length === 0) {
return keepEntries
.map((entry) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
.join('');
}
const merged = [...keepEntries];
const seen = new Set(keepEntries.map((entry) => entry.content));
for (const entry of sourceEntries) {
const key = entry.content;
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
}
const merged = this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]);
if (merged.length === 0) return existingValue;
return merged
.map((entry) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
@@ -6,6 +6,7 @@ import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/an
type NoteInfo = {
noteId: number;
fields: Record<string, { value: string }>;
tags?: string[];
};
type ManualChoice = {
@@ -23,6 +24,7 @@ type FieldGroupingCallback = (data: {
function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const deleted: number[][] = [];
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
const statuses: string[] = [];
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
const mergeCalls: Array<{
@@ -49,6 +51,9 @@ function createWorkflowHarness() {
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
updates.push({ noteId, fields });
},
addTags: async (noteIds: number[], tags: string[]) => {
addedTags.push({ noteIds, tags });
},
deleteNotes: async (noteIds: number[]) => {
deleted.push(noteIds);
},
@@ -117,6 +122,7 @@ function createWorkflowHarness() {
workflow: new FieldGroupingWorkflow(deps),
updates,
deleted,
addedTags,
rememberedMerges,
statuses,
mergeCalls,
@@ -145,6 +151,31 @@ test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate b
assert.equal(harness.statuses.length, 1);
});
test('FieldGroupingWorkflow merges source tags into target and filters special source tags', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async (noteIds: number[]) =>
noteIds.map((noteId) => ({
noteId,
fields: {
Expression: { value: `word-${noteId}` },
Sentence: { value: `line-${noteId}` },
},
tags:
noteId === 1 ? ['kinkoi', 'marked'] : ['SubMiner', 'marked', 'leech', 'potential_leech'],
}));
await harness.workflow.handleAuto(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
tags: ['SubMiner', 'marked', 'leech', 'potential_leech'],
});
assert.deepEqual(harness.addedTags, [{ noteIds: [1], tags: ['SubMiner'] }]);
});
test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => {
const harness = createWorkflowHarness();
@@ -4,12 +4,14 @@ import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
export interface FieldGroupingWorkflowNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
tags?: string[];
}
export interface FieldGroupingWorkflowDeps {
client: {
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
addTags(noteIds: number[], tags: string[]): Promise<void>;
deleteNotes(noteIds: number[]): Promise<void>;
};
getConfig: () => {
@@ -156,6 +158,11 @@ export class FieldGroupingWorkflow {
await this.deps.addConfiguredTagsToNote(keepNoteId);
}
const tagsToAdd = this.getMergeTagsToAdd(keepNoteInfo, deleteNoteInfo);
if (tagsToAdd.length > 0) {
await this.deps.client.addTags([keepNoteId], tagsToAdd);
}
if (deleteDuplicate) {
await this.deps.client.deleteNotes([deleteNoteId]);
this.deps.removeTrackedNoteId(deleteNoteId);
@@ -200,6 +207,24 @@ export class FieldGroupingWorkflow {
return getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig());
}
private getMergeTagsToAdd(
keepNoteInfo: FieldGroupingWorkflowNoteInfo,
deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
): string[] {
const targetTags = new Set((keepNoteInfo.tags ?? []).map((tag) => tag.trim()).filter(Boolean));
const unwantedSourceTags = new Set(['leech', 'marked', 'potential_leech']);
const tagsToAdd: string[] = [];
for (const rawTag of deleteNoteInfo.tags ?? []) {
const tag = rawTag.trim();
if (!tag || targetTags.has(tag) || unwantedSourceTags.has(tag)) continue;
targetTags.add(tag);
tagsToAdd.push(tag);
}
return tagsToAdd;
}
private async resolveFieldGroupingCallback(): Promise<
| ((data: {
original: KikuDuplicateCardInfo;
@@ -5,6 +5,7 @@ import {
type NoteUpdateWorkflowDeps,
type NoteUpdateWorkflowNoteInfo,
} from './note-update-workflow';
import type { SubtitleMiningContext } from '../types/subtitle';
function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
@@ -203,3 +204,72 @@ test('NoteUpdateWorkflow passes animated image lead-in when syncing avif to word
assert.equal(receivedLeadInSeconds, 1.25);
});
test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing', async () => {
const harness = createWorkflowHarness();
const sidebarContext = {
source: 'subtitle-sidebar' as const,
text: 'sidebar previous line',
startTime: 10,
endTime: 12,
capturedAtMs: 123,
};
let audioContext: unknown = null;
let imageContext: unknown = null;
let miscInfoStartTime: number | undefined;
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: 'sidebar previous line' },
SentenceAudio: { value: '' },
Picture: { value: '' },
MiscInfo: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
image: 'Picture',
miscInfo: 'MiscInfo',
},
media: {
generateAudio: true,
generateImage: true,
imageType: 'avif',
},
behavior: {},
});
harness.deps.getCurrentSubtitleText = () => 'current primary line';
harness.deps.getCurrentSubtitleStart = () => 20;
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
audioContext = context ?? null;
return Buffer.from('audio');
};
harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => {
imageContext = context ?? null;
return Buffer.from('image');
};
harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => {
miscInfoStartTime = startTimeSeconds;
return `start:${startTimeSeconds}`;
};
(
harness.deps as NoteUpdateWorkflowDeps & {
consumeSubtitleMiningContext: () => typeof sidebarContext | null;
}
).consumeSubtitleMiningContext = () => sidebarContext;
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.equal(harness.updates[0]?.fields.Sentence, 'sidebar previous line');
assert.deepEqual(audioContext, sidebarContext);
assert.deepEqual(imageContext, sidebarContext);
assert.equal(miscInfoStartTime, 10);
});
+72 -6
View File
@@ -1,5 +1,6 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
import type { SubtitleMiningContext } from '../types/subtitle';
export interface NoteUpdateWorkflowNoteInfo {
noteId: number;
@@ -65,10 +66,14 @@ export interface NoteUpdateWorkflowDeps {
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
generateAudioFilename: () => string;
generateAudio: () => Promise<Buffer | null>;
generateAudio: (context?: SubtitleMiningContext) => Promise<Buffer | null>;
generateImageFilename: () => string;
generateImage: (animatedLeadInSeconds?: number) => Promise<Buffer | null>;
generateImage: (
animatedLeadInSeconds?: number,
context?: SubtitleMiningContext,
) => Promise<Buffer | null>;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void;
@@ -79,9 +84,62 @@ export interface NoteUpdateWorkflowDeps {
logError: (message: string, ...args: unknown[]) => void;
}
function normalizeSubtitleContextText(text: string): string {
return text
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function hasUsableSubtitleContextTiming(context: SubtitleMiningContext): boolean {
return (
Number.isFinite(context.startTime) &&
Number.isFinite(context.endTime) &&
context.endTime > context.startTime
);
}
function subtitleContextMatchesSentence(contextText: string, noteSentence: string): boolean {
const normalizedContext = normalizeSubtitleContextText(contextText);
const normalizedSentence = normalizeSubtitleContextText(noteSentence);
if (!normalizedContext || !normalizedSentence) {
return false;
}
return (
normalizedContext === normalizedSentence ||
normalizedContext.includes(normalizedSentence) ||
normalizedSentence.includes(normalizedContext)
);
}
export class NoteUpdateWorkflow {
constructor(private readonly deps: NoteUpdateWorkflowDeps) {}
private consumeMatchingSubtitleMiningContext(
fields: Record<string, string>,
sentenceField: string,
configuredSentenceField?: string,
): SubtitleMiningContext | null {
const context = this.deps.consumeSubtitleMiningContext?.() ?? null;
if (!context || !hasUsableSubtitleContextTiming(context)) {
return null;
}
const candidateFields = [
sentenceField,
configuredSentenceField,
DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
];
const noteSentence = candidateFields
.map((fieldName) => (fieldName ? fields[fieldName.toLowerCase()] : undefined))
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
if (!noteSentence || subtitleContextMatchesSentence(context.text, noteSentence)) {
return context;
}
return null;
}
async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> {
this.deps.beginUpdateProgress('Updating card');
try {
@@ -121,8 +179,13 @@ export class NoteUpdateWorkflow {
let updatePerformed = false;
let miscInfoFilename: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField;
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
fields,
sentenceField,
config.fields?.sentence,
);
const currentSubtitleText = this.deps.getCurrentSubtitleText();
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
if (sentenceField && currentSubtitleText) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence;
@@ -132,7 +195,7 @@ export class NoteUpdateWorkflow {
if (config.media?.generateAudio) {
try {
const audioFilename = this.deps.generateAudioFilename();
const audioBuffer = await this.deps.generateAudio();
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
@@ -158,7 +221,10 @@ export class NoteUpdateWorkflow {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.deps.generateImageFilename();
const imageBuffer = await this.deps.generateImage(animatedLeadInSeconds);
const imageBuffer = await this.deps.generateImage(
animatedLeadInSeconds,
subtitleMiningContext ?? undefined,
);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
@@ -189,7 +255,7 @@ export class NoteUpdateWorkflow {
if (config.fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || '',
this.deps.getCurrentSubtitleStart(),
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
);
const miscInfoField = this.deps.resolveConfiguredFieldName(
noteInfo,