fix(anki): respect configured fields for word-card enrichment

- Keep dedicated Lapis/Kiku sentence-card fields unchanged
This commit is contained in:
2026-08-28 23:35:59 -07:00
parent 6e945f0872
commit 299c1d3173
11 changed files with 236 additions and 59 deletions
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Word-card enrichment now always writes sentence text and audio to the configured AnkiConnect fields, while dedicated Lapis/Kiku sentence-card and audio-card actions keep their compatible field names.
+5 -1
View File
@@ -136,6 +136,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error. Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline). Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
### Minimal Config ### Minimal Config
@@ -233,7 +235,7 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
When media is available, mined-card overlay and system notifications include the same current-frame thumbnail. When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged. `overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio in `ankiConnect.fields.audio`, even when `overwriteAudio` is disabled.
## AI Translation ## AI Translation
@@ -287,6 +289,8 @@ Sentence card creation and audio card marking require a non-empty `ankiConnect.i
Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is created directly via AnkiConnect with the sentence, audio, and image filled in. Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is created directly via AnkiConnect with the sentence, audio, and image filled in.
The dedicated sentence-card and audio-card shortcuts use the Lapis/Kiku-compatible `Sentence` and `SentenceAudio` fields. This does not affect the configured fields used to enrich normal word cards.
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (19) to select how many recent lines to combine. To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (19) to select how many recent lines to combine.
## Word Card Type (Kiku/Lapis) ## Word Card Type (Kiku/Lapis)
+1 -1
View File
@@ -41,7 +41,7 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard. - For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation - the same fields auto-update would fill. 3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation - the same fields auto-update would fill.
Manual clipboard updates always replace generated sentence audio, even when `ankiConnect.behavior.overwriteAudio` is disabled. The word audio field is left unchanged because the word itself does not change in this flow. Manual clipboard updates always replace generated sentence audio in `ankiConnect.fields.audio`, even when `ankiConnect.behavior.overwriteAudio` is disabled. Normal word-card updates use the configured sentence and audio fields even when Lapis or Kiku support is enabled.
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card. This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
+5 -3
View File
@@ -608,6 +608,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
{ {
fields: { fields: {
audio: 'ExpressionAudio',
image: 'Picture', image: 'Picture',
}, },
media: { media: {
@@ -661,7 +662,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
noteIds.map((noteId) => ({ noteIds.map((noteId) => ({
noteId, noteId,
fields: { fields: {
SentenceAudio: { value: '' }, ExpressionAudio: { value: '' },
Picture: { value: '' }, Picture: { value: '' },
}, },
})), })),
@@ -946,7 +947,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
noteInfo: { noteInfo: {
noteId: 404, noteId: 404,
fields: { fields: {
SentenceAudio: { value: '' }, ExpressionAudio: { value: '' },
Picture: { value: '' }, Picture: { value: '' },
}, },
}, },
@@ -958,7 +959,8 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
assert.equal(queued, true); assert.equal(queued, true);
assert.equal(updatedNotes.length, 1); assert.equal(updatedNotes.length, 1);
assert.equal(updatedNotes[0]?.noteId, 404); assert.equal(updatedNotes[0]?.noteId, 404);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/); assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio_/);
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/); assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
assert.equal(storedMedia.length, 2); assert.equal(storedMedia.length, 2);
assert.deepEqual(audioVolumeScales, [0.3 ** 3]); assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
-11
View File
@@ -383,8 +383,6 @@ export class AnkiIntegration {
getCachedMediaPath: this.getCachedMediaPath, getCachedMediaPath: this.getCachedMediaPath,
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true, shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context), getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames), this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
mergeFieldValue: (existing, newValue, overwrite) => mergeFieldValue: (existing, newValue, overwrite) =>
@@ -661,8 +659,6 @@ export class AnkiIntegration {
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind), this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames), this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo),
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo), getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
mergeFieldValue: (existing, newValue, overwrite) => mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite), this.mergeFieldValue(existing, newValue, overwrite),
@@ -1567,13 +1563,6 @@ export class AnkiIntegration {
return sentenceCardConfig.audioField || 'SentenceAudio'; return sentenceCardConfig.audioField || 'SentenceAudio';
} }
private getResolvedSentenceAudioFieldName(noteInfo: NoteInfo): string | null {
return (
this.resolveNoteFieldName(noteInfo, this.getPreferredSentenceAudioFieldName()) ||
this.resolveConfiguredFieldName(noteInfo, this.config.fields?.audio)
);
}
private getConfiguredWordFieldName(): string { private getConfiguredWordFieldName(): string {
return getConfiguredWordFieldName(this.config); return getConfiguredWordFieldName(this.config);
} }
@@ -143,7 +143,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
}; };
} }
test('manual clipboard subtitle update replaces sentence audio without touching expression audio', async () => { test('manual clipboard subtitle update replaces audio in the configured field', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService(); const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
await service.updateLastAddedFromClipboard('字幕'); await service.updateLastAddedFromClipboard('字幕');
@@ -151,14 +151,144 @@ test('manual clipboard subtitle update replaces sentence audio without touching
assert.equal(updatedFields.length, 1); assert.equal(updatedFields.length, 1);
assert.equal(storedMedia.length, 1); assert.equal(storedMedia.length, 1);
const audioValue = `[sound:${storedMedia[0]}]`; const audioValue = `[sound:${storedMedia[0]}]`;
assert.equal(updatedFields[0]?.SentenceAudio, audioValue); assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
assert.equal('ExpressionAudio' in updatedFields[0]!, false); assert.equal('SentenceAudio' in updatedFields[0]!, false);
assert.deepEqual( assert.deepEqual(
mergeCalls.map((call) => call.overwrite), mergeCalls.map((call) => call.overwrite),
[true], [true],
); );
}); });
test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Context',
audio: 'ContextAudio',
},
media: {
generateAudio: true,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: false,
overwriteImage: false,
},
ai: false,
}) as AnkiConnectConfig,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Context: { value: '' },
ContextAudio: { value: '' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
});
await service.updateLastAddedFromClipboard('字幕');
assert.equal(updatedFields.length, 1);
assert.match(updatedFields[0]?.ContextAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.deepEqual(Object.keys(updatedFields[0] ?? {}).sort(), ['Context', 'ContextAudio']);
assert.equal(updatedFields[0]?.Context, '字幕');
});
test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Context',
audio: 'ContextAudio',
},
media: {
generateAudio: true,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentAudioStreamIndex: 0,
currentSubText: '字幕',
currentSubStart: 12,
currentSubEnd: 14,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Context: { value: '' },
ContextAudio: { value: '' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
});
await service.markLastCardAsAudioCard();
assert.equal(updatedFields.length, 1);
assert.equal(updatedFields[0]?.Sentence, '字幕');
assert.match(updatedFields[0]?.SentenceAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.equal('Context' in (updatedFields[0] ?? {}), false);
assert.equal('ContextAudio' in (updatedFields[0] ?? {}), false);
});
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => { test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
const { service, updatedFields } = createManualUpdateService({ const { service, updatedFields } = createManualUpdateService({
getConfig: () => getConfig: () =>
@@ -225,7 +355,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
}); });
}); });
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => { test('manual clipboard subtitle update uses configured audio when SentenceAudio is missing', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({ const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
client: { client: {
addNote: async () => 0, addNote: async () => 0,
@@ -255,8 +385,9 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
assert.equal(storedMedia.length, 1); assert.equal(storedMedia.length, 1);
assert.equal(updatedFields.length, 1); assert.equal(updatedFields.length, 1);
assert.deepEqual(updatedFields[0], { Sentence: '字幕' }); assert.match(updatedFields[0]?.ExpressionAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.equal(mergeCalls.length, 0); assert.equal(updatedFields[0]?.Sentence, '字幕');
assert.equal(mergeCalls.length, 1);
}); });
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => { test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
+11 -21
View File
@@ -260,9 +260,16 @@ export class CardCreationService {
fields, fields,
this.deps.getConfig(), this.deps.getConfig(),
); );
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo); const config = this.deps.getConfig();
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField; const sentenceField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
);
const sentence = blocks.join(' '); const sentence = blocks.join(' ');
const updatedFields: Record<string, string> = {}; const updatedFields: Record<string, string> = {};
@@ -284,7 +291,6 @@ export class CardCreationService {
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`, `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
); );
const config = this.deps.getConfig();
const generateAudio = shouldGenerateAudio(config); const generateAudio = shouldGenerateAudio(config);
const generateImage = shouldGenerateImage(config); const generateImage = shouldGenerateImage(config);
const mediaResolverOptions = this.getMediaResolverOptions(); const mediaResolverOptions = this.getMediaResolverOptions();
@@ -457,13 +463,13 @@ export class CardCreationService {
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio'); this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio');
const sentenceField = this.deps.getConfig().fields?.sentence; const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField;
if (sentenceField) { if (sentenceField) {
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields); const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
updatedFields[sentenceField] = processedSentence; updatedFields[sentenceField] = processedSentence;
} }
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const audioFieldName = sentenceCardConfig.audioField; const audioFieldName = sentenceCardConfig.audioField;
try { try {
const audioFilename = this.generateAudioFilename(); const audioFilename = this.generateAudioFilename();
@@ -806,22 +812,6 @@ export class CardCreationService {
} }
} }
private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
return (
this.deps.resolveNoteFieldName(
noteInfo,
this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
) || this.deps.resolveConfiguredFieldName(noteInfo, this.deps.getConfig().fields?.audio)
);
}
private getResolvedSentenceOnlyAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
return this.deps.resolveNoteFieldName(
noteInfo,
this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
);
}
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo { private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
return { return {
noteId: -1, noteId: -1,
@@ -80,7 +80,6 @@ function createWorkflowHarness() {
const names = Object.keys(noteInfo.fields); const names = Object.keys(noteInfo.fields);
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null; return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
}, },
getResolvedSentenceAudioFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0, getAnimatedImageLeadInSeconds: async () => 0,
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next, mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
generateAudioFilename: () => 'audio_1.mp3', generateAudioFilename: () => 'audio_1.mp3',
@@ -120,6 +119,49 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
assert.equal(harness.notifications.length, 1); assert.equal(harness.notifications.length, 1);
}); });
test('NoteUpdateWorkflow uses configured fields for word-card enrichment with Lapis and Kiku enabled', async () => {
const harness = createWorkflowHarness();
harness.deps.getConfig = () => ({
fields: {
sentence: 'Context',
audio: 'ContextAudio',
},
media: {
generateAudio: true,
generateImage: false,
},
behavior: {},
});
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Context: { value: '' },
ContextAudio: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.generateAudio = async () => Buffer.from('audio');
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Context: 'subtitle-text',
ContextAudio: '[sound:audio_1.mp3]',
});
});
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => { test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
const harness = createWorkflowHarness(); const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => harness.deps.client.notesInfo = async () =>
@@ -432,6 +474,7 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
harness.deps.getConfig = () => ({ harness.deps.getConfig = () => ({
fields: { fields: {
sentence: 'Sentence', sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture', image: 'Picture',
miscInfo: 'MiscInfo', miscInfo: 'MiscInfo',
}, },
@@ -444,7 +487,6 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
}); });
harness.deps.getCurrentSubtitleText = () => 'current primary line'; harness.deps.getCurrentSubtitleText = () => 'current primary line';
harness.deps.getCurrentSubtitleStart = () => 20; harness.deps.getCurrentSubtitleStart = () => 20;
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => { harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
audioContext = context ?? null; audioContext = context ?? null;
return Buffer.from('audio'); return Buffer.from('audio');
@@ -501,6 +543,7 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
harness.deps.getConfig = () => ({ harness.deps.getConfig = () => ({
fields: { fields: {
sentence: 'Sentence', sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture', image: 'Picture',
miscInfo: 'MiscInfo', miscInfo: 'MiscInfo',
}, },
@@ -511,7 +554,6 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
}, },
behavior: {}, behavior: {},
}); });
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.captureSubtitleMediaContext = () => { harness.deps.captureSubtitleMediaContext = () => {
captureCalls += 1; captureCalls += 1;
return capturedContext; return capturedContext;
+10 -5
View File
@@ -19,6 +19,7 @@ export interface NoteUpdateWorkflowDeps {
fields?: { fields?: {
word?: string; word?: string;
sentence?: string; sentence?: string;
audio?: string;
image?: string; image?: string;
miscInfo?: string; miscInfo?: string;
}; };
@@ -75,7 +76,6 @@ export interface NoteUpdateWorkflowDeps {
noteInfo: NoteUpdateWorkflowNoteInfo, noteInfo: NoteUpdateWorkflowNoteInfo,
...preferredNames: (string | undefined)[] ...preferredNames: (string | undefined)[]
) => string | null; ) => string | null;
getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>; getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
generateAudioFilename: () => string; generateAudioFilename: () => string;
@@ -198,11 +198,13 @@ export class NoteUpdateWorkflow {
const updatedFields: Record<string, string> = {}; const updatedFields: Record<string, string> = {};
let updatePerformed = false; let updatePerformed = false;
let miscInfoFilename: string | null = null; let miscInfoFilename: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField; const configuredSentenceField =
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence;
const sentenceField = this.deps.resolveConfiguredFieldName(noteInfo, configuredSentenceField);
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext( const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
fields, fields,
sentenceField, sentenceField ?? configuredSentenceField,
config.fields?.sentence, configuredSentenceField,
); );
// Audio and image generation run sequentially and audio extraction can take tens of // Audio and image generation run sequentially and audio extraction can take tens of
// seconds, so resolve the clip range exactly once up front; reading live mpv sub // seconds, so resolve the clip range exactly once up front; reading live mpv sub
@@ -258,7 +260,10 @@ export class NoteUpdateWorkflow {
if (audioBuffer) { if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer); await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo); const sentenceAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
if (sentenceAudioField) { if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || ''; const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue( updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
@@ -31,7 +31,6 @@ function createDeps(
getCachedMediaPath: async () => null, getCachedMediaPath: async () => null,
shouldRequireRemoteMediaCache: () => true, shouldRequireRemoteMediaCache: () => true,
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }), getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
resolveConfiguredFieldName: () => 'Picture', resolveConfiguredFieldName: () => 'Picture',
mergeFieldValue: (_existing, newValue) => newValue, mergeFieldValue: (_existing, newValue) => newValue,
getAnimatedImageLeadInSeconds: async () => 0, getAnimatedImageLeadInSeconds: async () => 0,
@@ -133,7 +132,7 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
noteIds.map((noteId) => ({ noteIds.map((noteId) => ({
noteId, noteId,
fields: { fields: {
SentenceAudio: { value: '' }, ExpressionAudio: { value: '' },
Picture: { value: '' }, Picture: { value: '' },
}, },
})), })),
@@ -144,13 +143,16 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
storedMedia.push(filename); storedMedia.push(filename);
}, },
}, },
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig, getConfig: () =>
({ media: {}, fields: { audio: 'ExpressionAudio', image: 'Picture' } }) as AnkiConnectConfig,
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
preferredNames.find((name) => name && name in noteInfo.fields) ?? null,
}); });
const queue = new PendingYoutubeMediaQueue(deps); const queue = new PendingYoutubeMediaQueue(deps);
const queued = await queue.queueFromNote({ const queued = await queue.queueFromNote({
noteId: 42, noteId: 42,
noteInfo: { noteId: 42, fields: {} }, noteInfo: { noteId: 42, fields: { ExpressionAudio: { value: '' } } },
label: 'demo', label: 'demo',
}); });
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv'); await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
@@ -158,7 +160,8 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
assert.equal(queued, true); assert.equal(queued, true);
assert.equal(updatedNotes.length, 1); assert.equal(updatedNotes.length, 1);
assert.equal(storedMedia.length, 2); assert.equal(storedMedia.length, 2);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/); assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio\.mp3\]$/);
assert.equal('SentenceAudio' in (updatedNotes[0]?.fields ?? {}), false);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/); assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
}); });
@@ -39,7 +39,6 @@ export interface PendingYoutubeMediaQueueDeps {
startTime: number; startTime: number;
endTime: number; endTime: number;
}; };
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
resolveConfiguredFieldName: ( resolveConfiguredFieldName: (
noteInfo: PendingYoutubeMediaNoteInfo, noteInfo: PendingYoutubeMediaNoteInfo,
...preferredNames: (string | undefined)[] ...preferredNames: (string | undefined)[]
@@ -136,7 +135,7 @@ export class PendingYoutubeMediaQueue {
startTime: mediaRange.startTime, startTime: mediaRange.startTime,
endTime: mediaRange.endTime, endTime: mediaRange.endTime,
label: job.label, label: job.label,
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined, audioFieldName: this.resolveConfiguredAudioFieldName(job.noteInfo) ?? undefined,
imageFieldName: imageFieldName:
this.deps.resolveConfiguredFieldName( this.deps.resolveConfiguredFieldName(
job.noteInfo, job.noteInfo,
@@ -247,6 +246,14 @@ export class PendingYoutubeMediaQueue {
return matched; return matched;
} }
private resolveConfiguredAudioFieldName(noteInfo: PendingYoutubeMediaNoteInfo): string | null {
const config = this.deps.getConfig();
return this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
}
private async applyUpdate( private async applyUpdate(
job: PendingYoutubeMediaUpdate, job: PendingYoutubeMediaUpdate,
cachedPath: string, cachedPath: string,
@@ -283,7 +290,7 @@ export class PendingYoutubeMediaQueue {
if (audioBuffer) { if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer); await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const audioField = const audioField =
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null; job.audioFieldName || this.resolveConfiguredAudioFieldName(noteInfo) || null;
if (audioField) { if (audioField) {
const existingAudio = noteInfo.fields[audioField]?.value || ''; const existingAudio = noteInfo.fields[audioField]?.value || '';
mediaFields[audioField] = this.deps.mergeFieldValue( mediaFields[audioField] = this.deps.mergeFieldValue(