mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
refactor(anki): extract workflow services from integration facade
Split note-update and field-grouping orchestration out of AnkiIntegration so the facade remains focused on composition and shared policy wiring. This keeps mining behavior stable while creating focused workflow seams with dedicated regression coverage and clearer ownership docs.
This commit is contained in:
@@ -46,6 +46,8 @@ import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki
|
||||
import { CardCreationService } from './anki-integration/card-creation';
|
||||
import { FieldGroupingService } from './anki-integration/field-grouping';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
|
||||
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
||||
|
||||
const log = createLogger('anki').child('integration');
|
||||
|
||||
@@ -80,6 +82,8 @@ export class AnkiIntegration {
|
||||
private cardCreationService: CardCreationService;
|
||||
private fieldGroupingMergeCollaborator: FieldGroupingMergeCollaborator;
|
||||
private fieldGroupingService: FieldGroupingService;
|
||||
private noteUpdateWorkflow: NoteUpdateWorkflow;
|
||||
private fieldGroupingWorkflow: FieldGroupingWorkflow;
|
||||
|
||||
constructor(
|
||||
config: AnkiConnectConfig,
|
||||
@@ -106,6 +110,8 @@ export class AnkiIntegration {
|
||||
this.cardCreationService = this.createCardCreationService();
|
||||
this.fieldGroupingMergeCollaborator = this.createFieldGroupingMergeCollaborator();
|
||||
this.fieldGroupingService = this.createFieldGroupingService();
|
||||
this.noteUpdateWorkflow = this.createNoteUpdateWorkflow();
|
||||
this.fieldGroupingWorkflow = this.createFieldGroupingWorkflow();
|
||||
}
|
||||
|
||||
private createFieldGroupingMergeCollaborator(): FieldGroupingMergeCollaborator {
|
||||
@@ -309,6 +315,90 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
private createNoteUpdateWorkflow(): NoteUpdateWorkflow {
|
||||
return new NoteUpdateWorkflow({
|
||||
client: {
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
||||
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
||||
},
|
||||
getConfig: () => this.config,
|
||||
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
||||
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
|
||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||
extractFields: (fields) => this.extractFields(fields),
|
||||
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
|
||||
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
|
||||
handleFieldGroupingAuto: (originalNoteId, newNoteId, newNoteInfo, expression) =>
|
||||
this.handleFieldGroupingAuto(originalNoteId, newNoteId, newNoteInfo, expression),
|
||||
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
|
||||
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
|
||||
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||
this.getResolvedSentenceAudioFieldName(noteInfo),
|
||||
mergeFieldValue: (existing, newValue, overwrite) =>
|
||||
this.mergeFieldValue(existing, newValue, overwrite),
|
||||
generateAudioFilename: () => this.generateAudioFilename(),
|
||||
generateAudio: () => this.generateAudio(),
|
||||
generateImageFilename: () => this.generateImageFilename(),
|
||||
generateImage: () => this.generateImage(),
|
||||
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
|
||||
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
|
||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showOsdNotification(message),
|
||||
beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage),
|
||||
endUpdateProgress: () => this.endUpdateProgress(),
|
||||
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||
});
|
||||
}
|
||||
|
||||
private createFieldGroupingWorkflow(): FieldGroupingWorkflow {
|
||||
return new FieldGroupingWorkflow({
|
||||
client: {
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
||||
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||
},
|
||||
getConfig: () => this.config,
|
||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
||||
getFieldGroupingCallback: () => this.fieldGroupingCallback,
|
||||
computeFieldGroupingMergedFields: (
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepNoteInfo,
|
||||
deleteNoteInfo,
|
||||
includeGeneratedMedia,
|
||||
) =>
|
||||
this.fieldGroupingMergeCollaborator.computeFieldGroupingMergedFields(
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepNoteInfo,
|
||||
deleteNoteInfo,
|
||||
includeGeneratedMedia,
|
||||
),
|
||||
extractFields: (fields) => this.extractFields(fields),
|
||||
hasFieldValue: (noteInfo, preferredFieldName) =>
|
||||
this.hasFieldValue(noteInfo, preferredFieldName),
|
||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||
removeTrackedNoteId: (noteId) => {
|
||||
this.previousNoteIds.delete(noteId);
|
||||
},
|
||||
showStatusNotification: (message) => this.showStatusNotification(message),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showOsdNotification(message),
|
||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||
truncateSentence: (sentence) => this.truncateSentence(sentence),
|
||||
});
|
||||
}
|
||||
|
||||
isKnownWord(text: string): boolean {
|
||||
return this.knownWordCache.isKnownWord(text);
|
||||
}
|
||||
@@ -434,146 +524,7 @@ export class AnkiIntegration {
|
||||
noteId: number,
|
||||
options?: { skipKikuFieldGrouping?: boolean },
|
||||
): Promise<void> {
|
||||
this.beginUpdateProgress('Updating card');
|
||||
try {
|
||||
const notesInfoResult = await this.client.notesInfo([noteId]);
|
||||
const notesInfo = notesInfoResult as unknown as NoteInfo[];
|
||||
if (!notesInfo || notesInfo.length === 0) {
|
||||
log.warn('Card not found:', noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteInfo = notesInfo[0]!;
|
||||
this.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
const fields = this.extractFields(noteInfo.fields);
|
||||
|
||||
const expressionText = fields.expression || fields.word || '';
|
||||
if (!expressionText) {
|
||||
log.warn('No expression/word field found in card:', noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||
if (
|
||||
!options?.skipKikuFieldGrouping &&
|
||||
sentenceCardConfig.kikuEnabled &&
|
||||
sentenceCardConfig.kikuFieldGrouping !== 'disabled'
|
||||
) {
|
||||
const duplicateNoteId = await this.findDuplicateNote(expressionText, noteId, noteInfo);
|
||||
if (duplicateNoteId !== null) {
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
|
||||
await this.handleFieldGroupingAuto(duplicateNoteId, noteId, noteInfo, expressionText);
|
||||
return;
|
||||
} else if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
|
||||
const handled = await this.handleFieldGroupingManual(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
noteInfo,
|
||||
expressionText,
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFields: Record<string, string> = {};
|
||||
let updatePerformed = false;
|
||||
let miscInfoFilename: string | null = null;
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
|
||||
if (sentenceField && this.mpvClient.currentSubText) {
|
||||
const processedSentence = this.processSentence(this.mpvClient.currentSubText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
updatePerformed = true;
|
||||
}
|
||||
|
||||
if (this.config.media?.generateAudio && this.mpvClient) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.generateAudio();
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
|
||||
if (sentenceAudioField) {
|
||||
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
|
||||
updatedFields[sentenceAudioField] = this.mergeFieldValue(
|
||||
existingAudio,
|
||||
`[sound:${audioFilename}]`,
|
||||
this.config.behavior?.overwriteAudio !== false,
|
||||
);
|
||||
}
|
||||
miscInfoFilename = audioFilename;
|
||||
updatePerformed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate audio:', (error as Error).message);
|
||||
this.showOsdNotification(`Audio generation failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
let imageBuffer: Buffer | null = null;
|
||||
if (this.config.media?.generateImage && this.mpvClient) {
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
imageBuffer = await this.generateImage();
|
||||
|
||||
if (imageBuffer) {
|
||||
await this.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
const imageFieldName = this.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
this.config.fields?.image,
|
||||
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
|
||||
);
|
||||
if (!imageFieldName) {
|
||||
log.warn('Image field not found on note, skipping image update');
|
||||
} else {
|
||||
const existingImage = noteInfo.fields[imageFieldName]?.value || '';
|
||||
updatedFields[imageFieldName] = this.mergeFieldValue(
|
||||
existingImage,
|
||||
`<img src="${imageFilename}">`,
|
||||
this.config.behavior?.overwriteImage !== false,
|
||||
);
|
||||
miscInfoFilename = imageFilename;
|
||||
updatePerformed = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate image:', (error as Error).message);
|
||||
this.showOsdNotification(`Image generation failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.fields?.miscInfo) {
|
||||
const miscInfo = this.formatMiscInfoPattern(
|
||||
miscInfoFilename || '',
|
||||
this.mpvClient.currentSubStart,
|
||||
);
|
||||
const miscInfoField = this.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
this.config.fields?.miscInfo,
|
||||
);
|
||||
if (miscInfo && miscInfoField) {
|
||||
updatedFields[miscInfoField] = miscInfo;
|
||||
updatePerformed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatePerformed) {
|
||||
await this.client.updateNoteFields(noteId, updatedFields);
|
||||
await this.addConfiguredTagsToNote(noteId);
|
||||
log.info('Updated card fields for:', expressionText);
|
||||
await this.showNotification(noteId, expressionText);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('note was not found')) {
|
||||
log.warn('Card was deleted before update:', noteId);
|
||||
} else {
|
||||
log.error('Error processing new card:', (error as Error).message);
|
||||
}
|
||||
} finally {
|
||||
this.endUpdateProgress();
|
||||
}
|
||||
await this.noteUpdateWorkflow.execute(noteId, options);
|
||||
}
|
||||
|
||||
private extractFields(fields: Record<string, { value: string }>): Record<string, string> {
|
||||
@@ -1077,66 +1028,14 @@ export class AnkiIntegration {
|
||||
);
|
||||
}
|
||||
|
||||
private async performFieldGroupingMerge(
|
||||
keepNoteId: number,
|
||||
deleteNoteId: number,
|
||||
deleteNoteInfo: NoteInfo,
|
||||
expression: string,
|
||||
deleteDuplicate = true,
|
||||
): Promise<void> {
|
||||
const keepNotesInfoResult = await this.client.notesInfo([keepNoteId]);
|
||||
const keepNotesInfo = keepNotesInfoResult as unknown as NoteInfo[];
|
||||
if (!keepNotesInfo || keepNotesInfo.length === 0) {
|
||||
log.warn('Keep note not found:', keepNoteId);
|
||||
return;
|
||||
}
|
||||
const keepNoteInfo = keepNotesInfo[0]!;
|
||||
const mergedFields = await this.fieldGroupingMergeCollaborator.computeFieldGroupingMergedFields(
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepNoteInfo,
|
||||
deleteNoteInfo,
|
||||
true,
|
||||
);
|
||||
|
||||
if (Object.keys(mergedFields).length > 0) {
|
||||
await this.client.updateNoteFields(keepNoteId, mergedFields);
|
||||
await this.addConfiguredTagsToNote(keepNoteId);
|
||||
}
|
||||
|
||||
if (deleteDuplicate) {
|
||||
await this.client.deleteNotes([deleteNoteId]);
|
||||
this.previousNoteIds.delete(deleteNoteId);
|
||||
}
|
||||
|
||||
log.info('Merged duplicate card:', expression, 'into note:', keepNoteId);
|
||||
this.showStatusNotification(
|
||||
deleteDuplicate
|
||||
? `Merged duplicate: ${expression}`
|
||||
: `Grouped duplicate (kept both): ${expression}`,
|
||||
);
|
||||
await this.showNotification(keepNoteId, expression);
|
||||
}
|
||||
|
||||
private async handleFieldGroupingAuto(
|
||||
originalNoteId: number,
|
||||
newNoteId: number,
|
||||
newNoteInfo: NoteInfo,
|
||||
expression: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||
await this.performFieldGroupingMerge(
|
||||
originalNoteId,
|
||||
newNoteId,
|
||||
newNoteInfo,
|
||||
expression,
|
||||
sentenceCardConfig.kikuDeleteDuplicateInAuto,
|
||||
);
|
||||
} catch (error) {
|
||||
log.error('Field grouping auto merge failed:', (error as Error).message);
|
||||
this.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
|
||||
}
|
||||
void expression;
|
||||
await this.fieldGroupingWorkflow.handleAuto(originalNoteId, newNoteId, newNoteInfo);
|
||||
}
|
||||
|
||||
private async handleFieldGroupingManual(
|
||||
@@ -1145,79 +1044,8 @@ export class AnkiIntegration {
|
||||
newNoteInfo: NoteInfo,
|
||||
expression: string,
|
||||
): Promise<boolean> {
|
||||
if (!this.fieldGroupingCallback) {
|
||||
log.warn('No field grouping callback registered, skipping manual mode');
|
||||
this.showOsdNotification('Field grouping UI unavailable');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalNotesInfoResult = await this.client.notesInfo([originalNoteId]);
|
||||
const originalNotesInfo = originalNotesInfoResult as unknown as NoteInfo[];
|
||||
if (!originalNotesInfo || originalNotesInfo.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const originalNoteInfo = originalNotesInfo[0]!;
|
||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||
|
||||
const originalFields = this.extractFields(originalNoteInfo.fields);
|
||||
const newFields = this.extractFields(newNoteInfo.fields);
|
||||
|
||||
const originalCard: KikuDuplicateCardInfo = {
|
||||
noteId: originalNoteId,
|
||||
expression: originalFields.expression || originalFields.word || expression,
|
||||
sentencePreview: this.truncateSentence(
|
||||
originalFields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] || '',
|
||||
),
|
||||
hasAudio:
|
||||
this.hasFieldValue(originalNoteInfo, this.config.fields?.audio) ||
|
||||
this.hasFieldValue(originalNoteInfo, sentenceCardConfig.audioField),
|
||||
hasImage: this.hasFieldValue(originalNoteInfo, this.config.fields?.image),
|
||||
isOriginal: true,
|
||||
};
|
||||
|
||||
const newCard: KikuDuplicateCardInfo = {
|
||||
noteId: newNoteId,
|
||||
expression: newFields.expression || newFields.word || expression,
|
||||
sentencePreview: this.truncateSentence(
|
||||
newFields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] ||
|
||||
this.mpvClient.currentSubText ||
|
||||
'',
|
||||
),
|
||||
hasAudio:
|
||||
this.hasFieldValue(newNoteInfo, this.config.fields?.audio) ||
|
||||
this.hasFieldValue(newNoteInfo, sentenceCardConfig.audioField),
|
||||
hasImage: this.hasFieldValue(newNoteInfo, this.config.fields?.image),
|
||||
isOriginal: false,
|
||||
};
|
||||
|
||||
const choice = await this.fieldGroupingCallback({
|
||||
original: originalCard,
|
||||
duplicate: newCard,
|
||||
});
|
||||
|
||||
if (choice.cancelled) {
|
||||
this.showOsdNotification('Field grouping cancelled');
|
||||
return false;
|
||||
}
|
||||
|
||||
const keepNoteId = choice.keepNoteId;
|
||||
const deleteNoteId = choice.deleteNoteId;
|
||||
const deleteNoteInfo = deleteNoteId === newNoteId ? newNoteInfo : originalNoteInfo;
|
||||
|
||||
await this.performFieldGroupingMerge(
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
deleteNoteInfo,
|
||||
expression,
|
||||
choice.deleteDuplicate,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error('Field grouping manual merge failed:', (error as Error).message);
|
||||
this.showOsdNotification(`Field grouping failed: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
void expression;
|
||||
return this.fieldGroupingWorkflow.handleManual(originalNoteId, newNoteId, newNoteInfo);
|
||||
}
|
||||
|
||||
private truncateSentence(sentence: string): string {
|
||||
|
||||
114
src/anki-integration/field-grouping-workflow.test.ts
Normal file
114
src/anki-integration/field-grouping-workflow.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FieldGroupingWorkflow } from './field-grouping-workflow';
|
||||
|
||||
type NoteInfo = {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
};
|
||||
|
||||
function createWorkflowHarness() {
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const deleted: number[][] = [];
|
||||
const statuses: string[] = [];
|
||||
|
||||
const deps = {
|
||||
client: {
|
||||
notesInfo: async (noteIds: number[]) =>
|
||||
noteIds.map(
|
||||
(noteId) =>
|
||||
({
|
||||
noteId,
|
||||
fields: {
|
||||
Expression: { value: `word-${noteId}` },
|
||||
Sentence: { value: `line-${noteId}` },
|
||||
},
|
||||
}) satisfies NoteInfo,
|
||||
),
|
||||
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
|
||||
updates.push({ noteId, fields });
|
||||
},
|
||||
deleteNotes: async (noteIds: number[]) => {
|
||||
deleted.push(noteIds);
|
||||
},
|
||||
},
|
||||
getConfig: () => ({
|
||||
fields: {
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
isKiku: {
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
}),
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
kikuDeleteDuplicateInAuto: true,
|
||||
}),
|
||||
getCurrentSubtitleText: () => 'subtitle-text',
|
||||
getFieldGroupingCallback: () => null,
|
||||
setFieldGroupingCallback: () => undefined,
|
||||
computeFieldGroupingMergedFields: async () => ({
|
||||
Sentence: 'merged sentence',
|
||||
}),
|
||||
extractFields: (fields: Record<string, { value: string }>) => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
out[key.toLowerCase()] = value.value;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
hasFieldValue: (_noteInfo: NoteInfo, _field?: string) => false,
|
||||
addConfiguredTagsToNote: async () => undefined,
|
||||
removeTrackedNoteId: () => undefined,
|
||||
showStatusNotification: (message: string) => {
|
||||
statuses.push(message);
|
||||
},
|
||||
showNotification: async () => undefined,
|
||||
showOsdNotification: () => undefined,
|
||||
logError: () => undefined,
|
||||
logInfo: () => undefined,
|
||||
truncateSentence: (value: string) => value,
|
||||
};
|
||||
|
||||
return {
|
||||
workflow: new FieldGroupingWorkflow(deps),
|
||||
updates,
|
||||
deleted,
|
||||
statuses,
|
||||
deps,
|
||||
};
|
||||
}
|
||||
|
||||
test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate by default', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
|
||||
await harness.workflow.handleAuto(1, 2, {
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Expression: { value: 'word-2' },
|
||||
Sentence: { value: 'line-2' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.equal(harness.updates[0]?.noteId, 1);
|
||||
assert.deepEqual(harness.deleted, [[2]]);
|
||||
assert.equal(harness.statuses.length, 1);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
|
||||
const handled = await harness.workflow.handleManual(1, 2, {
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Expression: { value: 'word-2' },
|
||||
Sentence: { value: 'line-2' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.equal(harness.updates.length, 0);
|
||||
});
|
||||
214
src/anki-integration/field-grouping-workflow.ts
Normal file
214
src/anki-integration/field-grouping-workflow.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
|
||||
|
||||
export interface FieldGroupingWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
export interface FieldGroupingWorkflowDeps {
|
||||
client: {
|
||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
};
|
||||
getConfig: () => {
|
||||
fields?: {
|
||||
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;
|
||||
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,
|
||||
newNoteInfo,
|
||||
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;
|
||||
const deleteNoteInfo = deleteNoteId === newNoteId ? newNoteInfo : originalNoteInfo;
|
||||
|
||||
await this.performMerge(
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
deleteNoteInfo,
|
||||
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,
|
||||
deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
|
||||
expression: string,
|
||||
deleteDuplicate = true,
|
||||
): Promise<void> {
|
||||
const keepNotesInfoResult = await this.deps.client.notesInfo([keepNoteId]);
|
||||
const keepNotesInfo = keepNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
||||
if (!keepNotesInfo || keepNotesInfo.length === 0) {
|
||||
this.deps.logInfo('Keep note not found:', keepNoteId);
|
||||
return;
|
||||
}
|
||||
|
||||
const keepNoteInfo = keepNotesInfo[0]!;
|
||||
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);
|
||||
}
|
||||
|
||||
if (deleteDuplicate) {
|
||||
await this.deps.client.deleteNotes([deleteNoteId]);
|
||||
this.deps.removeTrackedNoteId(deleteNoteId);
|
||||
}
|
||||
|
||||
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: fields.expression || fields.word || 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 fields.expression || fields.word || '';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
111
src/anki-integration/note-update-workflow.test.ts
Normal file
111
src/anki-integration/note-update-workflow.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { NoteUpdateWorkflow } from './note-update-workflow';
|
||||
|
||||
type NoteInfo = {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
};
|
||||
|
||||
function createWorkflowHarness() {
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const notifications: Array<{ noteId: number; label: string | number }> = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const deps = {
|
||||
client: {
|
||||
notesInfo: async (_noteIds: number[]) =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteInfo[],
|
||||
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
|
||||
updates.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
},
|
||||
getConfig: () => ({
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
},
|
||||
media: {},
|
||||
behavior: {},
|
||||
}),
|
||||
getCurrentSubtitleText: () => 'subtitle-text',
|
||||
getCurrentSubtitleStart: () => 12.3,
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled' as const,
|
||||
}),
|
||||
appendKnownWordsFromNoteInfo: (_noteInfo: NoteInfo) => undefined,
|
||||
extractFields: (fields: Record<string, { value: string }>) => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
out[key.toLowerCase()] = value.value;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
findDuplicateNote: async () => null,
|
||||
handleFieldGroupingAuto: async () => undefined,
|
||||
handleFieldGroupingManual: async () => false,
|
||||
processSentence: (text: string) => text,
|
||||
resolveConfiguredFieldName: (noteInfo: NoteInfo, preferred?: string) => {
|
||||
if (!preferred) return null;
|
||||
const names = Object.keys(noteInfo.fields);
|
||||
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
|
||||
},
|
||||
getResolvedSentenceAudioFieldName: () => null,
|
||||
mergeFieldValue: (_existing: string, next: string) => next,
|
||||
generateAudioFilename: () => 'audio_1.mp3',
|
||||
generateAudio: async () => null,
|
||||
generateImageFilename: () => 'image_1.jpg',
|
||||
generateImage: async () => null,
|
||||
formatMiscInfoPattern: () => '',
|
||||
addConfiguredTagsToNote: async () => undefined,
|
||||
showNotification: async (noteId: number, label: string | number) => {
|
||||
notifications.push({ noteId, label });
|
||||
},
|
||||
showOsdNotification: (_text: string) => undefined,
|
||||
beginUpdateProgress: (_text: string) => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
logWarn: (message: string) => warnings.push(message),
|
||||
logInfo: (_message: string) => undefined,
|
||||
logError: (_message: string) => undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
workflow: new NoteUpdateWorkflow(deps),
|
||||
updates,
|
||||
notifications,
|
||||
warnings,
|
||||
deps,
|
||||
};
|
||||
}
|
||||
|
||||
test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.equal(harness.updates[0]?.noteId, 42);
|
||||
assert.equal(harness.updates[0]?.fields.Sentence, 'subtitle-text');
|
||||
assert.equal(harness.notifications.length, 1);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () => [];
|
||||
|
||||
await harness.workflow.execute(777);
|
||||
|
||||
assert.equal(harness.updates.length, 0);
|
||||
assert.equal(harness.notifications.length, 0);
|
||||
assert.equal(harness.warnings.length, 1);
|
||||
});
|
||||
232
src/anki-integration/note-update-workflow.ts
Normal file
232
src/anki-integration/note-update-workflow.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
export interface NoteUpdateWorkflowDeps {
|
||||
client: {
|
||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||
};
|
||||
getConfig: () => {
|
||||
fields?: {
|
||||
sentence?: string;
|
||||
image?: string;
|
||||
miscInfo?: string;
|
||||
};
|
||||
media?: {
|
||||
generateAudio?: boolean;
|
||||
generateImage?: boolean;
|
||||
};
|
||||
behavior?: {
|
||||
overwriteAudio?: boolean;
|
||||
overwriteImage?: boolean;
|
||||
};
|
||||
};
|
||||
getCurrentSubtitleText: () => string | undefined;
|
||||
getCurrentSubtitleStart: () => number | undefined;
|
||||
getEffectiveSentenceCardConfig: () => {
|
||||
sentenceField: string;
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
};
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||
findDuplicateNote: (
|
||||
expression: string,
|
||||
excludeNoteId: number,
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
) => Promise<number | null>;
|
||||
handleFieldGroupingAuto: (
|
||||
originalNoteId: number,
|
||||
newNoteId: number,
|
||||
newNoteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
expression: string,
|
||||
) => Promise<void>;
|
||||
handleFieldGroupingManual: (
|
||||
originalNoteId: number,
|
||||
newNoteId: number,
|
||||
newNoteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
expression: string,
|
||||
) => Promise<boolean>;
|
||||
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
|
||||
resolveConfiguredFieldName: (
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
) => string | null;
|
||||
getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
|
||||
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
||||
generateAudioFilename: () => string;
|
||||
generateAudio: () => Promise<Buffer | null>;
|
||||
generateImageFilename: () => string;
|
||||
generateImage: () => Promise<Buffer | null>;
|
||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
showOsdNotification: (message: string) => void;
|
||||
beginUpdateProgress: (initialMessage: string) => void;
|
||||
endUpdateProgress: () => void;
|
||||
logWarn: (message: string, ...args: unknown[]) => void;
|
||||
logInfo: (message: string, ...args: unknown[]) => void;
|
||||
logError: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export class NoteUpdateWorkflow {
|
||||
constructor(private readonly deps: NoteUpdateWorkflowDeps) {}
|
||||
|
||||
async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> {
|
||||
this.deps.beginUpdateProgress('Updating card');
|
||||
try {
|
||||
const notesInfoResult = await this.deps.client.notesInfo([noteId]);
|
||||
const notesInfo = notesInfoResult as NoteUpdateWorkflowNoteInfo[];
|
||||
if (!notesInfo || notesInfo.length === 0) {
|
||||
this.deps.logWarn('Card not found:', noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteInfo = notesInfo[0]!;
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
|
||||
const expressionText = fields.expression || fields.word || '';
|
||||
if (!expressionText) {
|
||||
this.deps.logWarn('No expression/word field found in card:', noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
if (
|
||||
!options?.skipKikuFieldGrouping &&
|
||||
sentenceCardConfig.kikuEnabled &&
|
||||
sentenceCardConfig.kikuFieldGrouping !== 'disabled'
|
||||
) {
|
||||
const duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
|
||||
if (duplicateNoteId !== null) {
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
|
||||
await this.deps.handleFieldGroupingAuto(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
noteInfo,
|
||||
expressionText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
|
||||
const handled = await this.deps.handleFieldGroupingManual(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
noteInfo,
|
||||
expressionText,
|
||||
);
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFields: Record<string, string> = {};
|
||||
let updatePerformed = false;
|
||||
let miscInfoFilename: string | null = null;
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
|
||||
const currentSubtitleText = this.deps.getCurrentSubtitleText();
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
updatePerformed = true;
|
||||
}
|
||||
|
||||
const config = this.deps.getConfig();
|
||||
|
||||
if (config.media?.generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.deps.generateAudioFilename();
|
||||
const audioBuffer = await this.deps.generateAudio();
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo);
|
||||
if (sentenceAudioField) {
|
||||
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
|
||||
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
|
||||
existingAudio,
|
||||
`[sound:${audioFilename}]`,
|
||||
config.behavior?.overwriteAudio !== false,
|
||||
);
|
||||
}
|
||||
miscInfoFilename = audioFilename;
|
||||
updatePerformed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logError('Failed to generate audio:', (error as Error).message);
|
||||
this.deps.showOsdNotification(`Audio generation failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.media?.generateImage) {
|
||||
try {
|
||||
const imageFilename = this.deps.generateImageFilename();
|
||||
const imageBuffer = await this.deps.generateImage();
|
||||
|
||||
if (imageBuffer) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
const imageFieldName = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.image,
|
||||
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
|
||||
);
|
||||
if (!imageFieldName) {
|
||||
this.deps.logWarn('Image field not found on note, skipping image update');
|
||||
} else {
|
||||
const existingImage = noteInfo.fields[imageFieldName]?.value || '';
|
||||
updatedFields[imageFieldName] = this.deps.mergeFieldValue(
|
||||
existingImage,
|
||||
`<img src="${imageFilename}">`,
|
||||
config.behavior?.overwriteImage !== false,
|
||||
);
|
||||
miscInfoFilename = imageFilename;
|
||||
updatePerformed = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logError('Failed to generate image:', (error as Error).message);
|
||||
this.deps.showOsdNotification(`Image generation failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.fields?.miscInfo) {
|
||||
const miscInfo = this.deps.formatMiscInfoPattern(
|
||||
miscInfoFilename || '',
|
||||
this.deps.getCurrentSubtitleStart(),
|
||||
);
|
||||
const miscInfoField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.miscInfo,
|
||||
);
|
||||
if (miscInfo && miscInfoField) {
|
||||
updatedFields[miscInfoField] = miscInfo;
|
||||
updatePerformed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatePerformed) {
|
||||
await this.deps.client.updateNoteFields(noteId, updatedFields);
|
||||
await this.deps.addConfiguredTagsToNote(noteId);
|
||||
this.deps.logInfo('Updated card fields for:', expressionText);
|
||||
await this.deps.showNotification(noteId, expressionText);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('note was not found')) {
|
||||
this.deps.logWarn('Card was deleted before update:', noteId);
|
||||
} else {
|
||||
this.deps.logError('Error processing new card:', (error as Error).message);
|
||||
}
|
||||
} finally {
|
||||
this.deps.endUpdateProgress();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user