mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-29 00:15:32 -07:00
fix(anki): respect configured fields for word-card enrichment
- Keep dedicated Lapis/Kiku sentence-card fields unchanged
This commit is contained in:
@@ -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.
|
||||||
@@ -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 (1–9) 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 (1–9) to select how many recent lines to combine.
|
||||||
|
|
||||||
## Word Card Type (Kiku/Lapis)
|
## Word Card Type (Kiku/Lapis)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user