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:
2026-02-21 13:23:53 -08:00
parent 54109deb94
commit 5cb0ee1591
7 changed files with 822 additions and 276 deletions

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-76 id: TASK-76
title: Decompose anki-integration orchestrator into workflow services title: Decompose anki-integration orchestrator into workflow services
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-02-18 11:43' created_date: '2026-02-18 11:43'
updated_date: '2026-02-18 11:43' updated_date: '2026-02-21 21:16'
labels: labels:
- anki - anki
- refactor - refactor
@@ -41,15 +41,40 @@ priority: high
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 `src/anki-integration.ts` reduced to orchestration/composition role - [x] #1 `src/anki-integration.ts` reduced to orchestration/composition role
- [ ] #2 Extracted workflow modules have focused tests - [x] #2 Extracted workflow modules have focused tests
- [ ] #3 Existing mining behavior remains unchanged in regression tests - [x] #3 Existing mining behavior remains unchanged in regression tests
- [ ] #4 Documentation updated with ownership boundaries - [x] #4 Documentation updated with ownership boundaries
<!-- AC:END --> <!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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).
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->
## Definition of Done ## Definition of Done
<!-- DOD:BEGIN --> <!-- DOD:BEGIN -->
- [ ] #1 `bun run test:core:dist` passes with Anki-related suites green - [x] #1 `bun run test:core:dist` passes with Anki-related suites green
- [ ] #2 No callsite API breakage outside planned changes - [x] #2 No callsite API breakage outside planned changes
<!-- DOD:END --> <!-- DOD:END -->

View File

@@ -22,6 +22,28 @@ SubMiner polls AnkiConnect at a regular interval (default: 3 seconds, configurab
Polling uses the query `"deck:<your-deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. Polling uses the query `"deck:<your-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 ## Field Mapping
SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`: SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`:

View File

@@ -46,6 +46,8 @@ import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki
import { CardCreationService } from './anki-integration/card-creation'; import { CardCreationService } from './anki-integration/card-creation';
import { FieldGroupingService } from './anki-integration/field-grouping'; import { FieldGroupingService } from './anki-integration/field-grouping';
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; 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'); const log = createLogger('anki').child('integration');
@@ -80,6 +82,8 @@ export class AnkiIntegration {
private cardCreationService: CardCreationService; private cardCreationService: CardCreationService;
private fieldGroupingMergeCollaborator: FieldGroupingMergeCollaborator; private fieldGroupingMergeCollaborator: FieldGroupingMergeCollaborator;
private fieldGroupingService: FieldGroupingService; private fieldGroupingService: FieldGroupingService;
private noteUpdateWorkflow: NoteUpdateWorkflow;
private fieldGroupingWorkflow: FieldGroupingWorkflow;
constructor( constructor(
config: AnkiConnectConfig, config: AnkiConnectConfig,
@@ -106,6 +110,8 @@ export class AnkiIntegration {
this.cardCreationService = this.createCardCreationService(); this.cardCreationService = this.createCardCreationService();
this.fieldGroupingMergeCollaborator = this.createFieldGroupingMergeCollaborator(); this.fieldGroupingMergeCollaborator = this.createFieldGroupingMergeCollaborator();
this.fieldGroupingService = this.createFieldGroupingService(); this.fieldGroupingService = this.createFieldGroupingService();
this.noteUpdateWorkflow = this.createNoteUpdateWorkflow();
this.fieldGroupingWorkflow = this.createFieldGroupingWorkflow();
} }
private createFieldGroupingMergeCollaborator(): FieldGroupingMergeCollaborator { 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 { isKnownWord(text: string): boolean {
return this.knownWordCache.isKnownWord(text); return this.knownWordCache.isKnownWord(text);
} }
@@ -434,146 +524,7 @@ export class AnkiIntegration {
noteId: number, noteId: number,
options?: { skipKikuFieldGrouping?: boolean }, options?: { skipKikuFieldGrouping?: boolean },
): Promise<void> { ): Promise<void> {
this.beginUpdateProgress('Updating card'); await this.noteUpdateWorkflow.execute(noteId, options);
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();
}
} }
private extractFields(fields: Record<string, { value: string }>): Record<string, string> { 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( private async handleFieldGroupingAuto(
originalNoteId: number, originalNoteId: number,
newNoteId: number, newNoteId: number,
newNoteInfo: NoteInfo, newNoteInfo: NoteInfo,
expression: string, expression: string,
): Promise<void> { ): Promise<void> {
try { void expression;
const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); await this.fieldGroupingWorkflow.handleAuto(originalNoteId, newNoteId, newNoteInfo);
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}`);
}
} }
private async handleFieldGroupingManual( private async handleFieldGroupingManual(
@@ -1145,79 +1044,8 @@ export class AnkiIntegration {
newNoteInfo: NoteInfo, newNoteInfo: NoteInfo,
expression: string, expression: string,
): Promise<boolean> { ): Promise<boolean> {
if (!this.fieldGroupingCallback) { void expression;
log.warn('No field grouping callback registered, skipping manual mode'); return this.fieldGroupingWorkflow.handleManual(originalNoteId, newNoteId, newNoteInfo);
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;
}
} }
private truncateSentence(sentence: string): string { private truncateSentence(sentence: string): string {

View 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);
});

View 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;
}
}

View 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);
});

View 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();
}
}
}