diff --git a/backlog/tasks/task-76 - Decompose-anki-integration-orchestrator-into-workflow-services.md b/backlog/tasks/task-76 - Decompose-anki-integration-orchestrator-into-workflow-services.md index 6dae2a2..5bf168a 100644 --- a/backlog/tasks/task-76 - Decompose-anki-integration-orchestrator-into-workflow-services.md +++ b/backlog/tasks/task-76 - Decompose-anki-integration-orchestrator-into-workflow-services.md @@ -1,10 +1,10 @@ --- id: TASK-76 title: Decompose anki-integration orchestrator into workflow services -status: To Do +status: Done assignee: [] created_date: '2026-02-18 11:43' -updated_date: '2026-02-18 11:43' +updated_date: '2026-02-21 21:16' labels: - anki - refactor @@ -41,15 +41,40 @@ priority: high ## Acceptance Criteria -- [ ] #1 `src/anki-integration.ts` reduced to orchestration/composition role -- [ ] #2 Extracted workflow modules have focused tests -- [ ] #3 Existing mining behavior remains unchanged in regression tests -- [ ] #4 Documentation updated with ownership boundaries +- [x] #1 `src/anki-integration.ts` reduced to orchestration/composition role +- [x] #2 Extracted workflow modules have focused tests +- [x] #3 Existing mining behavior remains unchanged in regression tests +- [x] #4 Documentation updated with ownership boundaries +## Implementation Notes + + +Execution started via writing-plans + executing-plans workflow. + +Plan: docs/plans/2026-02-21-task-76-anki-workflow-services-plan.md + +Baseline LOC (2026-02-21): src/anki-integration.ts=1315; existing anki-integration collaborators total=2271 (src/anki-integration/*.ts excluding facade). + +Implemented workflow-service decomposition in `src/anki-integration.ts` by extracting `src/anki-integration/note-update-workflow.ts` (new-card update pipeline) and `src/anki-integration/field-grouping-workflow.ts` (auto/manual grouping merge orchestration). + +Added focused workflow seam tests: `src/anki-integration/note-update-workflow.test.ts` and `src/anki-integration/field-grouping-workflow.test.ts`. + +Updated ownership boundaries docs in `docs/anki-integration.md` under "Ownership Boundaries (TASK-76)". + +Verification: `bun run build && node --test dist/anki-integration.test.js dist/anki-integration/note-update-workflow.test.js dist/anki-integration/field-grouping-workflow.test.js` (pass), and `bun run build && bun run test:core:dist` (pass). + +LOC delta: `src/anki-integration.ts` 1315 -> 1143 (-172 LOC). + + +## Final Summary + + +Decomposed `AnkiIntegration` into workflow services by extracting note update and field-grouping orchestration into dedicated modules while keeping public APIs stable. Added focused workflow seam tests, documented ownership boundaries, and validated behavior with Anki-focused dist tests plus full `test:core:dist` gate. + + ## Definition of Done -- [ ] #1 `bun run test:core:dist` passes with Anki-related suites green -- [ ] #2 No callsite API breakage outside planned changes +- [x] #1 `bun run test:core:dist` passes with Anki-related suites green +- [x] #2 No callsite API breakage outside planned changes - diff --git a/docs/anki-integration.md b/docs/anki-integration.md index 51a0e8f..ec64a66 100644 --- a/docs/anki-integration.md +++ b/docs/anki-integration.md @@ -22,6 +22,28 @@ SubMiner polls AnkiConnect at a regular interval (default: 3 seconds, configurab Polling uses the query `"deck:" added:1` to find recently added cards. If no deck is configured, it searches all decks. +## Ownership Boundaries (TASK-76) + +After workflow-service decomposition, ownership is split as follows: + +1. **`src/anki-integration.ts` (facade/orchestrator)** + - Owns dependency wiring, config normalization, shared helpers, and runtime lifecycle (`start`, `stop`, runtime patching). + - Routes public entry points to collaborators/workflows and keeps cross-cutting state (polling/update flags, notifications, callbacks, known-word cache). + +2. **`src/anki-integration/note-update-workflow.ts`** + - Owns the "new card was detected" update path. + - Loads note data, applies sentence/audio/image/misc updates, and triggers duplicate handling via auto/manual field-grouping handlers when enabled. + +3. **`src/anki-integration/field-grouping-workflow.ts`** + - Owns duplicate merge execution for auto and manual grouping. + - Resolves manual choice callback, computes merged field payloads, updates kept note, optionally deletes duplicate note, and emits grouping notifications. + +4. **Existing collaborators** + - `card-creation`: manual clipboard-driven updates, sentence-card creation, and card-type/tag update operations. + - `field-grouping` service: user-triggered grouping for the last added card and merge preview assembly. + - `known-word-cache`: known-word lifecycle/refresh/persistence used by N+1 highlighting. + - `polling`: periodic AnkiConnect polling, new-note detection, tracked-note state, and connection backoff. + ## Field Mapping SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`: diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 727f44d..3751ebc 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -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 { - 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 = {}; - 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, - ``, - 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): Record { @@ -1077,66 +1028,14 @@ export class AnkiIntegration { ); } - private async performFieldGroupingMerge( - keepNoteId: number, - deleteNoteId: number, - deleteNoteInfo: NoteInfo, - expression: string, - deleteDuplicate = true, - ): Promise { - 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 { - 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 { - 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 { diff --git a/src/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts new file mode 100644 index 0000000..08abf07 --- /dev/null +++ b/src/anki-integration/field-grouping-workflow.test.ts @@ -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; +}; + +function createWorkflowHarness() { + const updates: Array<{ noteId: number; fields: Record }> = []; + 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) => { + 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) => { + const out: Record = {}; + 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); +}); diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts new file mode 100644 index 0000000..3576acd --- /dev/null +++ b/src/anki-integration/field-grouping-workflow.ts @@ -0,0 +1,214 @@ +import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types'; + +export interface FieldGroupingWorkflowNoteInfo { + noteId: number; + fields: Record; +} + +export interface FieldGroupingWorkflowDeps { + client: { + notesInfo(noteIds: number[]): Promise; + updateNoteFields(noteId: number, fields: Record): Promise; + deleteNotes(noteIds: number[]): Promise; + }; + getConfig: () => { + fields?: { + audio?: string; + image?: string; + }; + }; + getEffectiveSentenceCardConfig: () => { + sentenceField: string; + audioField: string; + kikuDeleteDuplicateInAuto: boolean; + }; + getCurrentSubtitleText: () => string | undefined; + getFieldGroupingCallback: + | (() => Promise< + | ((data: { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; + }) => Promise) + | null + >) + | (() => + | ((data: { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; + }) => Promise) + | null); + computeFieldGroupingMergedFields: ( + keepNoteId: number, + deleteNoteId: number, + keepNoteInfo: FieldGroupingWorkflowNoteInfo, + deleteNoteInfo: FieldGroupingWorkflowNoteInfo, + includeGeneratedMedia: boolean, + ) => Promise>; + extractFields: (fields: Record) => Record; + hasFieldValue: (noteInfo: FieldGroupingWorkflowNoteInfo, preferredFieldName?: string) => boolean; + addConfiguredTagsToNote: (noteId: number) => Promise; + removeTrackedNoteId: (noteId: number) => void; + showStatusNotification: (message: string) => void; + showNotification: (noteId: number, label: string | number) => Promise; + 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 { + 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 { + 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 { + 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) + | null + > { + const callback = this.deps.getFieldGroupingCallback(); + if (callback instanceof Promise) { + return callback; + } + return callback; + } +} diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts new file mode 100644 index 0000000..85ab0e4 --- /dev/null +++ b/src/anki-integration/note-update-workflow.test.ts @@ -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; +}; + +function createWorkflowHarness() { + const updates: Array<{ noteId: number; fields: Record }> = []; + 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) => { + 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) => { + const out: Record = {}; + 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); +}); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts new file mode 100644 index 0000000..795a40b --- /dev/null +++ b/src/anki-integration/note-update-workflow.ts @@ -0,0 +1,232 @@ +import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; + +export interface NoteUpdateWorkflowNoteInfo { + noteId: number; + fields: Record; +} + +export interface NoteUpdateWorkflowDeps { + client: { + notesInfo(noteIds: number[]): Promise; + updateNoteFields(noteId: number, fields: Record): Promise; + storeMediaFile(filename: string, data: Buffer): Promise; + }; + 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) => Record; + findDuplicateNote: ( + expression: string, + excludeNoteId: number, + noteInfo: NoteUpdateWorkflowNoteInfo, + ) => Promise; + handleFieldGroupingAuto: ( + originalNoteId: number, + newNoteId: number, + newNoteInfo: NoteUpdateWorkflowNoteInfo, + expression: string, + ) => Promise; + handleFieldGroupingManual: ( + originalNoteId: number, + newNoteId: number, + newNoteInfo: NoteUpdateWorkflowNoteInfo, + expression: string, + ) => Promise; + processSentence: (mpvSentence: string, noteFields: Record) => 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; + generateImageFilename: () => string; + generateImage: () => Promise; + formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; + addConfiguredTagsToNote: (noteId: number) => Promise; + showNotification: (noteId: number, label: string | number) => Promise; + 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 { + 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 = {}; + 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, + ``, + 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(); + } + } +}