import { AnkiConnectConfig } from '../types/anki'; import { getConfiguredWordFieldName } from '../anki-field-config'; interface FieldGroupingMergeMedia { audioField?: string; audioValue?: string; imageField?: string; imageValue?: string; miscInfoValue?: string; } export interface FieldGroupingMergeNoteInfo { noteId: number; fields: Record; } interface FieldGroupingMergeDeps { getConfig: () => AnkiConnectConfig; getEffectiveSentenceCardConfig: () => { sentenceField: string; audioField: string; fieldGroupingProvider: 'kiku' | 'senren' | null; }; getCurrentSubtitleText: () => string | undefined; resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; resolveNoteFieldName: ( noteInfo: FieldGroupingMergeNoteInfo, preferredName?: string, ) => string | null; extractFields: (fields: Record) => Record; processSentence: (mpvSentence: string, noteFields: Record) => string; generateMediaForMerge: (noteInfo: FieldGroupingMergeNoteInfo) => Promise; warnFieldParseOnce: (fieldName: string, reason: string, detail?: string) => void; } export class FieldGroupingMergeCollaborator { private readonly strictGroupingFieldDefaults = new Set([ 'picture', 'sentence', 'sentenceaudio', 'sentencefurigana', 'miscinfo', ]); constructor(private readonly deps: FieldGroupingMergeDeps) {} getGroupableFieldNames(): string[] { const config = this.deps.getConfig(); const fields: string[] = []; fields.push('Sentence'); fields.push('SentenceAudio'); fields.push('Picture'); if (config.fields?.image) fields.push(config.fields?.image); if (config.fields?.sentence) fields.push(config.fields?.sentence); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceAudioField = sentenceCardConfig.audioField; if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField); if (config.fields?.miscInfo) fields.push(config.fields?.miscInfo); fields.push('SentenceFurigana'); return fields; } getNoteFieldMap(noteInfo: FieldGroupingMergeNoteInfo): Record { const fields: Record = {}; for (const [name, field] of Object.entries(noteInfo.fields)) { fields[name] = field?.value || ''; } return fields; } async computeFieldGroupingMergedFields( keepNoteId: number, deleteNoteId: number, keepNoteInfo: FieldGroupingMergeNoteInfo, deleteNoteInfo: FieldGroupingMergeNoteInfo, includeGeneratedMedia: boolean, ): Promise> { const config = this.deps.getConfig(); const configuredWordField = getConfiguredWordFieldName(config); const groupableFields = this.getGroupableFieldNames(); const keepFieldNames = Object.keys(keepNoteInfo.fields); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const senrenSourceSceneOffset = sentenceCardConfig.fieldGroupingProvider === 'senren' ? this.countSenrenAudioScenes( this.getResolvedFieldValue(keepNoteInfo, sentenceCardConfig.audioField), ) : 0; const sourceFields: Record = {}; const resolvedKeepFieldByPreferred = new Map(); for (const preferredFieldName of groupableFields) { sourceFields[preferredFieldName] = this.getResolvedFieldValue( deleteNoteInfo, preferredFieldName, ); const keepResolved = this.deps.resolveFieldName(keepFieldNames, preferredFieldName); if (keepResolved) { resolvedKeepFieldByPreferred.set(preferredFieldName, keepResolved); } } if (!sourceFields[configuredWordField] && sourceFields['Expression']) { sourceFields[configuredWordField] = sourceFields['Expression']; } if (!sourceFields[configuredWordField] && sourceFields['Word']) { sourceFields[configuredWordField] = sourceFields['Word']; } if (!sourceFields['Expression'] && sourceFields[configuredWordField]) { sourceFields['Expression'] = sourceFields[configuredWordField]; } if (!sourceFields['Word'] && sourceFields[configuredWordField]) { sourceFields['Word'] = sourceFields[configuredWordField]; } if ( config.fields?.sentence && !sourceFields[config.fields?.sentence] && this.deps.getCurrentSubtitleText() ) { const deleteFields = this.deps.extractFields(deleteNoteInfo.fields); sourceFields[config.fields?.sentence] = this.deps.processSentence( this.deps.getCurrentSubtitleText()!, deleteFields, ); } if (includeGeneratedMedia) { const media = await this.deps.generateMediaForMerge(keepNoteInfo); if (media.audioField && media.audioValue && !sourceFields[media.audioField]) { sourceFields[media.audioField] = media.audioValue; } if (media.imageField && media.imageValue && !sourceFields[media.imageField]) { sourceFields[media.imageField] = media.imageValue; } if ( config.fields?.miscInfo && media.miscInfoValue && !sourceFields[config.fields?.miscInfo] ) { sourceFields[config.fields?.miscInfo] = media.miscInfoValue; } } const mergedFields: Record = {}; for (const preferredFieldName of groupableFields) { const keepFieldName = resolvedKeepFieldByPreferred.get(preferredFieldName); if (!keepFieldName) continue; const keepFieldNormalized = keepFieldName.toLowerCase(); if ( keepFieldNormalized === 'expression' || keepFieldNormalized === configuredWordField.toLowerCase() || keepFieldNormalized === 'expressionfurigana' || keepFieldNormalized === 'expressionreading' || keepFieldNormalized === 'expressionaudio' ) { continue; } const existingValue = keepNoteInfo.fields[keepFieldName]?.value || ''; const newValue = sourceFields[preferredFieldName] || ''; const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName); if (!existingValue.trim() && !newValue.trim()) continue; if (keepFieldNormalized === 'sentencefurigana') { const hasBothValues = existingValue.trim().length > 0 && newValue.trim().length > 0; const usesSenrenGrouping = this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren'; mergedFields[keepFieldName] = hasBothValues || usesSenrenGrouping ? this.applyFieldGrouping( existingValue, newValue, keepNoteId, deleteNoteId, keepFieldName, senrenSourceSceneOffset, ) : ''; continue; } if (isStrictField) { mergedFields[keepFieldName] = this.applyFieldGrouping( existingValue, newValue, keepNoteId, deleteNoteId, keepFieldName, senrenSourceSceneOffset, ); } else if (existingValue.trim() && newValue.trim()) { mergedFields[keepFieldName] = this.applyFieldGrouping( existingValue, newValue, keepNoteId, deleteNoteId, keepFieldName, senrenSourceSceneOffset, ); } else { if (!newValue.trim()) continue; mergedFields[keepFieldName] = newValue; } } return mergedFields; } private getResolvedFieldValue( noteInfo: FieldGroupingMergeNoteInfo, preferredFieldName?: string, ): string { if (!preferredFieldName) return ''; const resolved = this.deps.resolveNoteFieldName(noteInfo, preferredFieldName); if (!resolved) return ''; return noteInfo.fields[resolved]?.value || ''; } private extractUngroupedValue(value: string): string { const ungrouped = this.extractUngroupedRemainder(value); if (ungrouped) return ungrouped; return value.trim(); } private extractUngroupedRemainder(value: string): string { const groupedSpanRegex = /]*data-group-id="[^"]*"[^>]*>[\s\S]*?<\/span>/gi; return value.replace(groupedSpanRegex, '').trim(); } private extractImageTags(value: string): string[] { const matches = value.match(/]*>/gi); return matches || []; } private ensureImageGroupId(imageTag: string, groupId: number): string { if (!imageTag) return ''; if (/data-group-id=/i.test(imageTag)) { return imageTag.replace(/data-group-id="[^"]*"/i, `data-group-id="${groupId}"`); } return imageTag.replace(/]*data-group-id="([^"]*)"[^>]*>/gi; let malformed; while ((malformed = malformedIdRegex.exec(value)) !== null) { const rawId = malformed[1]; const groupId = Number(rawId); if (!Number.isFinite(groupId) || groupId <= 0) { this.deps.warnFieldParseOnce(fieldName, 'invalid-group-id', rawId); } } const spanRegex = /]*data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi; let match; while ((match = spanRegex.exec(value)) !== null) { const groupId = Number(match[1]); if (!Number.isFinite(groupId) || groupId <= 0) continue; const content = this.normalizeStrictGroupedValue(match[2] || '', fieldName); if (!content) { this.deps.warnFieldParseOnce(fieldName, 'empty-group-content'); continue; } entries.push({ groupId, content }); } if (entries.length === 0 && / 0 ? this.extractUngroupedRemainder(value) : this.extractUngroupedValue(value); const ungrouped = this.normalizeStrictGroupedValue(ungroupedSource, fieldName); if (ungrouped) { entries.push({ groupId: fallbackGroupId, content: ungrouped }); } return entries; } private parsePictureEntries( value: string, fallbackGroupId: number, ): { groupId: number; tag: string }[] { const tags = this.extractImageTags(value); const result: { groupId: number; tag: string }[] = []; for (const tag of tags) { const idMatch = tag.match(/data-group-id="(\d+)"/i); let groupId = fallbackGroupId; if (idMatch) { const parsed = Number(idMatch[1]); if (!Number.isFinite(parsed) || parsed <= 0) { this.deps.warnFieldParseOnce('Picture', 'invalid-group-id', idMatch[1]); } else { groupId = parsed; } } const normalizedTag = this.ensureImageGroupId(tag, groupId); if (!normalizedTag) { this.deps.warnFieldParseOnce('Picture', 'empty-image-tag'); continue; } result.push({ groupId, tag: normalizedTag }); } return result; } private normalizeStrictGroupedValue(value: string, fieldName: string): string { const ungrouped = this.extractUngroupedValue(value); if (!ungrouped) return ''; const normalizedField = fieldName.toLowerCase(); if (normalizedField === 'sentenceaudio' && !/\[sound:[^\]]+\]/.test(ungrouped)) { this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); } return ungrouped; } private getStrictSpanGroupingFields(): Set { const strictFields = new Set(this.strictGroupingFieldDefaults); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); strictFields.add((sentenceCardConfig.sentenceField || 'sentence').toLowerCase()); strictFields.add((sentenceCardConfig.audioField || 'sentenceaudio').toLowerCase()); const config = this.deps.getConfig(); if (config.fields?.image) strictFields.add(config.fields.image.toLowerCase()); if (config.fields?.miscInfo) strictFields.add(config.fields.miscInfo.toLowerCase()); return strictFields; } private shouldUseStrictSpanGrouping(fieldName: string): boolean { const normalized = fieldName.toLowerCase(); 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(entries: T[]): T[] { return [...entries].sort((a, b) => b.groupId - a.groupId); } private isSentenceAudioField(fieldName: string): boolean { const normalized = fieldName.toLowerCase(); const audioField = ( this.deps.getEffectiveSentenceCardConfig().audioField || 'sentenceaudio' ).toLowerCase(); return normalized === 'sentenceaudio' || normalized === audioField; } private isSenrenGroupOpenTag(openTag: string): boolean { const classMatch = openTag.match(/class\s*=\s*"([^"]*)"/i) || openTag.match(/class\s*=\s*'([^']*)'/i); if (!classMatch) return false; // Senren's templates match class tokens case-sensitively (/^group\d*$/). return classMatch[1]!.split(/\s+/).some((token) => /^group\d*$/.test(token)); } private countSenrenAudioScenes(value: string): number { const soundEntries = value.match(/\[sound:[^\]]+\]/g)?.length ?? 0; if (soundEntries > 0) return soundEntries; return this.parseSenrenSceneEntries(value).length; } private rebaseSenrenGroup(entry: string, sceneOffset: number, sourceEntryIndex: number): string { if (sceneOffset <= 0) return entry; return entry.replace( /^(\s*]*?\bclass\s*=\s*)(["'])([^"']*)\2/i, (_match: string, prefix: string, quote: string, rawClasses: string) => { const classes = rawClasses .split(/(\s+)/) .map((classToken) => { if (classToken === 'group') { return `group${sceneOffset + sourceEntryIndex + 1}`; } const groupMatch = classToken.match(/^group(\d+)$/); if (!groupMatch) return classToken; const targetScene = Number(groupMatch[1]); if (!Number.isSafeInteger(targetScene) || targetScene <= 0) return classToken; return `group${targetScene + sceneOffset}`; }) .join(''); return `${prefix}${quote}${classes}${quote}`; }, ); } /** * Splits a Senren field into ordered scene entries. Top-level * ``/`"groupN"` spans are kept verbatim (nested markup like * `` included); ungrouped runs are wrapped in a group * span at their original position, because Senren discards anything outside a * group span once scene switching activates. */ private parseSenrenSceneEntries(value: string): string[] { const tokenRegex = /]*>|<\/span>/gi; const entries: string[] = []; const pushUngrouped = (raw: string): void => { const text = raw.replace(//gi, ' ').trim(); if (text) entries.push(`${text}`); }; let cursor = 0; let depth = 0; let entryStart = -1; let match; while ((match = tokenRegex.exec(value)) !== null) { const token = match[0]!; if (token[1] !== '/') { if (depth === 0 && this.isSenrenGroupOpenTag(token)) { pushUngrouped(value.slice(cursor, match.index)); entryStart = match.index; cursor = match.index; } depth += 1; } else { depth = Math.max(0, depth - 1); if (depth === 0 && entryStart !== -1) { const end = match.index + token.length; entries.push(value.slice(entryStart, end)); entryStart = -1; cursor = end; } } } if (entryStart !== -1) { // Unclosed group span: close every span still open (the group and any nested // markup) so the following scenes are siblings rather than nested inside it. entries.push(`${value.slice(entryStart)}${''.repeat(depth)}`); } else { pushUngrouped(`${value.slice(cursor)}${''.repeat(depth)}`); } return entries; } /** * Merges two notes' field values in Senren's scene-switching format. Scenes are * appended in order (existing first, never resorted) so indices stay aligned * across sentence/picture/miscInfo with the sentenceAudio entries, which alone * drive Senren's scene count. */ private applySenrenFieldGrouping( existingValue: string, newValue: string, fieldName: string, sourceSceneOffset: number, ): string { if (this.isPictureField(fieldName)) { const tags = [...this.extractImageTags(existingValue), ...this.extractImageTags(newValue)]; if (tags.length === 0) return existingValue || newValue; return tags.join(''); } if (this.isSentenceAudioField(fieldName)) { const existing = existingValue.trim(); const added = newValue.trim(); if (added && !/\[sound:[^\]]+\]/.test(added)) { this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); } if (!existing || !added) return existing || added; return existing + added; } const sourceEntries = this.parseSenrenSceneEntries(newValue).map((entry, sourceEntryIndex) => this.rebaseSenrenGroup(entry, sourceSceneOffset, sourceEntryIndex), ); const merged = [...this.parseSenrenSceneEntries(existingValue), ...sourceEntries]; if (merged.length === 0) return existingValue || newValue; return merged.join(''); } private applyFieldGrouping( existingValue: string, newValue: string, keepGroupId: number, sourceGroupId: number, fieldName: string, senrenSourceSceneOffset: number, ): string { if (this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren') { return this.applySenrenFieldGrouping( existingValue, newValue, fieldName, senrenSourceSceneOffset, ); } if (this.shouldUseStrictSpanGrouping(fieldName)) { 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; } return this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]) .map((entry) => entry.tag) .join(''); } const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName); const sourceEntries = this.parseStrictEntries(newValue, sourceGroupId, fieldName); if (keepEntries.length === 0 && sourceEntries.length === 0) { return existingValue || newValue; } const merged = this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]); if (merged.length === 0) return existingValue; return merged .map((entry) => `${entry.content}`) .join(''); } if (!existingValue.trim()) return newValue; if (!newValue.trim()) return existingValue; const hasGroups = /data-group-id/.test(existingValue); if (!hasGroups) { return `${existingValue}\n` + newValue; } const groupedSpanRegex = /[\s\S]*?<\/span>/g; let lastEnd = 0; let result = ''; let match; while ((match = groupedSpanRegex.exec(existingValue)) !== null) { const before = existingValue.slice(lastEnd, match.index); if (before.trim()) { result += `${before.trim()}\n`; } result += match[0] + '\n'; lastEnd = match.index + match[0].length; } const after = existingValue.slice(lastEnd); if (after.trim()) { result += `\n${after.trim()}`; } return result + '\n' + newValue; } }