diff --git a/backlog/tasks/task-299 - Force-audio-replacement-during-manual-subtitle-mining.md b/backlog/tasks/task-299 - Force-audio-replacement-during-manual-subtitle-mining.md new file mode 100644 index 00000000..b610bd54 --- /dev/null +++ b/backlog/tasks/task-299 - Force-audio-replacement-during-manual-subtitle-mining.md @@ -0,0 +1,66 @@ +--- +id: TASK-299 +title: Force audio replacement during manual subtitle mining +status: Done +assignee: + - Codex +created_date: '2026-04-26 00:10' +updated_date: '2026-04-26 02:42' +labels: + - anki + - mining +dependencies: [] +priority: medium +--- + +## Description + + +Manual subtitle mining via the Ctrl+C/Ctrl+V flow should replace expression and sentence audio fields even when the user has configured media overwrite fields to false. These fields can already contain proxy-inserted SubMiner audio on a new card, and manual update intent is to replace that generated content. + + +## Acceptance Criteria + +- [x] #1 Manual subtitle mining replaces existing expression audio content regardless of configured audio overwrite settings. +- [x] #2 Manual subtitle mining replaces existing sentence audio content regardless of configured audio overwrite settings. +- [x] #3 Non-manual mining/update flows continue to respect configured audio overwrite settings. +- [x] #4 A regression test covers manual audio replacement when overwrite settings are disabled. + + +## Implementation Plan + + +1. Locate the manual subtitle mining Ctrl+C/Ctrl+V flow and the Anki media field overwrite gate. +2. Add a failing regression test showing manual mining overwrites expression and sentence audio when configured audio overwrite is disabled. +3. Implement the smallest path-specific override so only manual subtitle mining forces audio replacement. +4. Run the focused mining test and update task acceptance criteria/final notes. + + +## Implementation Notes + + +Implemented focused manual clipboard update behavior in CardCreationService.updateLastAddedFromClipboard: generated manual audio is written to both resolved sentence audio and expression audio fields with forced overwrite. Other update flows still use existing overwrite config paths. + +Verification: focused Anki tests passed; typecheck passed; changelog lint and diff check passed. Full bun run test:fast was attempted but is blocked by unrelated existing tokenizer annotation-stage failures tied to dirty task 298 worktree changes. + + +## Final Summary + + +Summary: +- Manual clipboard subtitle updates now resolve both sentence audio and expression audio fields and replace both with the newly generated audio regardless of ankiConnect.behavior.overwriteAudio. +- Added a regression test for the Ctrl+C/Ctrl+V manual update path with existing proxy-inserted audio and overwriteAudio disabled. +- Registered the regression test in test:fast, documented the overwrite exception in user docs, and added a changelog fragment. + +Verification: +- bun test src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/card-creation.test.ts +- bun run tsc --noEmit +- bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts +- bun run changelog:lint +- bun run docs:test +- bun run docs:build +- git diff --check + +Blocked gate: +- bun run test:fast currently fails in unrelated src/core/services/tokenizer/annotation-stage.test.ts tests for kana-only non-independent noun helper merges; those files have pre-existing dirty changes outside this task. + diff --git a/changes/299-manual-subtitle-audio-overwrite.md b/changes/299-manual-subtitle-audio-overwrite.md new file mode 100644 index 00000000..6f5c309e --- /dev/null +++ b/changes/299-manual-subtitle-audio-overwrite.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Anki: Manual clipboard subtitle updates now replace both expression and sentence audio fields even when configured audio overwrite is disabled. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index cf1deb9a..7a1c05f8 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -213,6 +213,8 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) } ``` +`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated audio in both the expression audio field and sentence audio field. + ## AI Translation SubMiner can auto-translate the mined sentence and fill the translation field. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 36f54e8b..70fbe8c6 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -560,7 +560,7 @@ See `config.example.jsonc` for detailed configuration options. | `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | | `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | | `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | -| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+A"`) | +| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+A"`) | | `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | | `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Shift+H"`) | | `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | @@ -691,7 +691,7 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but | `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | | `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | | `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | -| `Ctrl+Alt+A` | Open character dictionary AniList selector | +| `Ctrl+Alt+A` | Open character dictionary AniList selector | | `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | | `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | @@ -857,59 +857,59 @@ This example is intentionally compact. The option table below documents availabl **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. -| Option | Values | Description | -| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | -| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | -| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | -| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | -| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) | -| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | -| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | -| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | -| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. | -| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | -| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | -| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | -| `fields.image` | string | Card field for images (default: `Picture`) | -| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | -| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | -| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | -| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | -| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | -| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | -| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | -| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | -| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | -| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | -| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | -| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | -| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | -| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | -| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | -| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | -| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | -| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | -| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) | -| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | -| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | -| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) | -| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) | -| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | -| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | -| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | -| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | -| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). | -| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | -| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | -| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | -| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). | -| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | -| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | -| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | -| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | -| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | -| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | +| Option | Values | Description | +| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | +| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | +| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | +| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | +| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) | +| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | +| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | +| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | +| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. | +| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | +| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | +| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | +| `fields.image` | string | Card field for images (default: `Picture`) | +| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | +| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | +| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | +| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | +| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | +| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | +| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | +| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | +| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | +| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | +| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | +| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | +| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | +| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | +| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | +| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | +| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | +| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | +| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) | +| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | +| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | +| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated audio (default: `true`) | +| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) | +| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | +| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | +| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | +| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | +| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). | +| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | +| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | +| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | +| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). | +| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | +| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | +| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | +| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | +| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | +| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | `ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config. diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index e2905c2c..5154a470 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -100,6 +100,8 @@ 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. 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 audio in both the expression audio field and sentence audio field, even when `ankiConnect.behavior.overwriteAudio` is disabled. The manual flow assumes you are intentionally replacing the proxy-generated clip on the newest card. + This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card. | Shortcut | Action | Config key | diff --git a/package.json b/package.json index 9239dbb7..a189db1e 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "test:launcher": "bun run test:launcher:src", "test:core": "bun run test:core:src", "test:subtitle": "bun run test:subtitle:src", - "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", + "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", "generate:config-example": "bun run src/generate-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts", "start": "bun run build && electron . --start", diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts new file mode 100644 index 00000000..fe1bea29 --- /dev/null +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { CardCreationService } from './card-creation'; +import type { AnkiConnectConfig } from '../types/anki'; + +type CardCreationDeps = ConstructorParameters[0]; + +function createManualUpdateService(overrides: Partial = {}): { + service: CardCreationService; + updatedFields: Record[]; + mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }>; + storedMedia: string[]; +} { + const updatedFields: Record[] = []; + const mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }> = []; + const storedMedia: string[] = []; + + const deps: CardCreationDeps = { + getConfig: () => + ({ + deck: 'Mining', + fields: { + word: 'Expression', + sentence: 'Sentence', + audio: 'ExpressionAudio', + }, + media: { + generateAudio: true, + generateImage: false, + maxMediaDuration: 30, + }, + behavior: { + overwriteAudio: false, + overwriteImage: false, + }, + ai: false, + }) as AnkiConnectConfig, + getAiConfig: () => ({}), + getTimingTracker: () => + ({ + findTiming: (text: string) => (text === '字幕' ? { startTime: 12, endTime: 14 } : null), + }) as never, + getMpvClient: () => + ({ + currentVideoPath: '/video.mp4', + currentAudioStreamIndex: 0, + }) as never, + client: { + addNote: async () => 0, + addTags: async () => undefined, + notesInfo: async () => [ + { + noteId: 42, + fields: { + Expression: { value: '単語' }, + Sentence: { value: '' }, + ExpressionAudio: { value: '[sound:auto-expression.mp3]' }, + SentenceAudio: { value: '[sound:auto-sentence.mp3]' }, + }, + }, + ], + updateNoteFields: async (_noteId, fields) => { + updatedFields.push(fields); + }, + storeMediaFile: async (filename) => { + storedMedia.push(filename); + }, + findNotes: async () => [42], + retrieveMediaFile: async () => '', + }, + mediaGenerator: { + generateAudio: async () => Buffer.from('audio'), + generateScreenshot: async () => null, + generateAnimatedImage: async () => null, + }, + showOsdNotification: () => undefined, + showUpdateResult: () => undefined, + showStatusNotification: () => undefined, + showNotification: async () => undefined, + beginUpdateProgress: () => undefined, + endUpdateProgress: () => undefined, + withUpdateProgress: async (_message, action) => action(), + resolveConfiguredFieldName: (noteInfo, ...preferredNames) => { + for (const preferredName of preferredNames) { + if (preferredName && preferredName in noteInfo.fields) return preferredName; + } + return null; + }, + resolveNoteFieldName: (noteInfo, preferredName) => + preferredName && preferredName in noteInfo.fields ? preferredName : null, + getAnimatedImageLeadInSeconds: async () => 0, + extractFields: (fields) => + Object.fromEntries( + Object.entries(fields).map(([name, field]) => [name.toLowerCase(), field.value]), + ), + processSentence: (sentence) => sentence, + setCardTypeFields: () => undefined, + mergeFieldValue: (existing, newValue, overwrite) => { + mergeCalls.push({ existing, newValue, overwrite }); + return overwrite || !existing.trim() ? newValue : existing; + }, + formatMiscInfoPattern: () => '', + getEffectiveSentenceCardConfig: () => ({ + model: 'Sentence', + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + lapisEnabled: false, + kikuEnabled: false, + kikuFieldGrouping: 'disabled', + kikuDeleteDuplicateInAuto: false, + }), + getFallbackDurationSeconds: () => 10, + appendKnownWordsFromNoteInfo: () => undefined, + isUpdateInProgress: () => false, + setUpdateInProgress: () => undefined, + trackLastAddedNoteId: () => undefined, + ...overrides, + }; + + return { + service: new CardCreationService(deps), + updatedFields, + mergeCalls, + storedMedia, + }; +} + +test('manual clipboard subtitle update replaces expression and sentence audio even when overwriteAudio is disabled', async () => { + const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService(); + + await service.updateLastAddedFromClipboard('字幕'); + + assert.equal(updatedFields.length, 1); + assert.equal(storedMedia.length, 1); + const audioValue = `[sound:${storedMedia[0]}]`; + assert.equal(updatedFields[0]?.ExpressionAudio, audioValue); + assert.equal(updatedFields[0]?.SentenceAudio, audioValue); + assert.deepEqual( + mergeCalls.map((call) => call.overwrite), + [true, true], + ); +}); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 375a6773..41c73515 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -219,6 +219,10 @@ export class CardCreationService { this.deps.getConfig(), ); const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo); + const expressionAudioField = this.deps.resolveConfiguredFieldName( + noteInfo, + this.deps.getConfig().fields?.audio || 'ExpressionAudio', + ); const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField; const sentence = blocks.join(' '); @@ -248,13 +252,21 @@ export class CardCreationService { if (audioBuffer) { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); - if (sentenceAudioField) { - const existingAudio = noteInfo.fields[sentenceAudioField]?.value || ''; - updatedFields[sentenceAudioField] = this.deps.mergeFieldValue( - existingAudio, - `[sound:${audioFilename}]`, - this.deps.getConfig().behavior?.overwriteAudio !== false, + if (sentenceAudioField || expressionAudioField) { + const audioValue = `[sound:${audioFilename}]`; + const audioFields = new Set( + [sentenceAudioField, expressionAudioField].filter( + (fieldName): fieldName is string => Boolean(fieldName), + ), ); + for (const audioField of audioFields) { + const existingAudio = noteInfo.fields[audioField]?.value || ''; + updatedFields[audioField] = this.deps.mergeFieldValue( + existingAudio, + audioValue, + true, + ); + } } miscInfoFilename = audioFilename; updatePerformed = true;