Files
SubMiner/src/anki-integration/field-grouping-workflow.ts
T
sudacode eb04ea97b1 fix: Kiku field grouping, frequency particles, sidebar media, Yomitan po
- Fix Kiku duplicate-card field grouping: local dupes trigger manual modal or auto-merge; fix modal-open ack race; fix merged field ordering, sentence-audio, furigana, and tag semantics
- Fix frequency annotations for single-token Yomitan compounds with internal particles (e.g. 目の前); keep pure grammar/kana spans unannotated
- Fix subtitle sidebar mining: use audio/image from clicked sidebar line, not current primary line
- Add `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitle visible while Yomitan popup is open
- Normalize trailing commas in config.example.jsonc
2026-05-27 00:12:21 -07:00

242 lines
8.4 KiB
TypeScript

import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/anki';
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: () => {
fields?: {
word?: string;
audio?: string;
image?: string;
};
};
getEffectiveSentenceCardConfig: () => {
sentenceField: string;
audioField: string;
kikuDeleteDuplicateInAuto: boolean;
};
getCurrentSubtitleText: () => string | undefined;
getFieldGroupingCallback:
| (() => Promise<
| ((data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>)
| null
>)
| (() =>
| ((data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>)
| null);
computeFieldGroupingMergedFields: (
keepNoteId: number,
deleteNoteId: number,
keepNoteInfo: FieldGroupingWorkflowNoteInfo,
deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
includeGeneratedMedia: boolean,
) => Promise<Record<string, string>>;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
hasFieldValue: (noteInfo: FieldGroupingWorkflowNoteInfo, preferredFieldName?: string) => boolean;
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
removeTrackedNoteId: (noteId: number) => void;
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void;
logError: (message: string, ...args: unknown[]) => void;
logInfo: (message: string, ...args: unknown[]) => void;
truncateSentence: (sentence: string) => string;
}
export class FieldGroupingWorkflow {
constructor(private readonly deps: FieldGroupingWorkflowDeps) {}
async handleAuto(
originalNoteId: number,
newNoteId: number,
newNoteInfo: FieldGroupingWorkflowNoteInfo,
): Promise<void> {
try {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
await this.performMerge(
originalNoteId,
newNoteId,
this.getExpression(newNoteInfo),
sentenceCardConfig.kikuDeleteDuplicateInAuto,
);
} catch (error) {
this.deps.logError('Field grouping auto merge failed:', (error as Error).message);
this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
}
}
async handleManual(
originalNoteId: number,
_newNoteId: number,
newNoteInfo: FieldGroupingWorkflowNoteInfo,
): Promise<boolean> {
const callback = await this.resolveFieldGroupingCallback();
if (!callback) {
this.deps.showOsdNotification('Field grouping UI unavailable');
return false;
}
try {
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
if (!originalNotesInfo || originalNotesInfo.length === 0) {
return false;
}
const originalNoteInfo = originalNotesInfo[0]!;
const expression = this.getExpression(newNoteInfo) || this.getExpression(originalNoteInfo);
const choice = await callback({
original: this.buildDuplicateCardInfo(originalNoteInfo, expression, true),
duplicate: this.buildDuplicateCardInfo(newNoteInfo, expression, false),
});
if (choice.cancelled) {
this.deps.showOsdNotification('Field grouping cancelled');
return false;
}
const keepNoteId = choice.keepNoteId;
const deleteNoteId = choice.deleteNoteId;
await this.performMerge(keepNoteId, deleteNoteId, expression, choice.deleteDuplicate);
return true;
} catch (error) {
this.deps.logError('Field grouping manual merge failed:', (error as Error).message);
this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
return false;
}
}
private async performMerge(
keepNoteId: number,
deleteNoteId: number,
expression: string,
deleteDuplicate = true,
): Promise<void> {
const notesInfoResult = await this.deps.client.notesInfo([keepNoteId, deleteNoteId]);
const notesInfo = notesInfoResult as FieldGroupingWorkflowNoteInfo[];
const keepNoteInfo = notesInfo.find((note) => note.noteId === keepNoteId);
const deleteNoteInfo = notesInfo.find((note) => note.noteId === deleteNoteId);
if (!keepNoteInfo) {
this.deps.logInfo('Keep note not found:', keepNoteId);
return;
}
if (!deleteNoteInfo) {
this.deps.logInfo('Delete note not found:', deleteNoteId);
return;
}
const mergedFields = await this.deps.computeFieldGroupingMergedFields(
keepNoteId,
deleteNoteId,
keepNoteInfo,
deleteNoteInfo,
true,
);
if (Object.keys(mergedFields).length > 0) {
await this.deps.client.updateNoteFields(keepNoteId, mergedFields);
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);
this.deps.rememberMergedNoteIds(deleteNoteId, keepNoteId);
}
this.deps.logInfo('Merged duplicate card:', expression, 'into note:', keepNoteId);
this.deps.showStatusNotification(
deleteDuplicate
? `Merged duplicate: ${expression}`
: `Grouped duplicate (kept both): ${expression}`,
);
await this.deps.showNotification(keepNoteId, expression);
}
private buildDuplicateCardInfo(
noteInfo: FieldGroupingWorkflowNoteInfo,
fallbackExpression: string,
isOriginal: boolean,
): KikuDuplicateCardInfo {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const fields = this.deps.extractFields(noteInfo.fields);
return {
noteId: noteInfo.noteId,
expression:
getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()) ||
fallbackExpression,
sentencePreview: this.deps.truncateSentence(
fields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] ||
(isOriginal ? '' : this.deps.getCurrentSubtitleText() || ''),
),
hasAudio:
this.deps.hasFieldValue(noteInfo, this.deps.getConfig().fields?.audio) ||
this.deps.hasFieldValue(noteInfo, sentenceCardConfig.audioField),
hasImage: this.deps.hasFieldValue(noteInfo, this.deps.getConfig().fields?.image),
isOriginal,
};
}
private getExpression(noteInfo: FieldGroupingWorkflowNoteInfo): string {
const fields = this.deps.extractFields(noteInfo.fields);
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;
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>)
| null
> {
const callback = this.deps.getFieldGroupingCallback();
if (callback instanceof Promise) {
return callback;
}
return callback;
}
}