mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-29 00:55:15 -07:00
fix: Kiku field grouping, frequency particles, sidebar media, Yomitan popup visibility (#91)
This commit is contained in:
@@ -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, '');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user