From 9e1c8f91b2d1e6914225d105393562afb51e5394 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 31 Aug 2026 23:29:55 -0700 Subject: [PATCH] feat(anki): support Senren scene-switching field grouping - Add Senren configuration, duplicate merging, and scene-aligned media fields - Document Senren setup and enforce Kiku/Senren exclusivity --- changes/senren-field-grouping.md | 5 + config.example.jsonc | 7 +- docs-site/anki-integration.md | 19 ++- docs-site/configuration.md | 6 +- docs-site/mining-workflow.md | 12 +- docs-site/public/config.example.jsonc | 7 +- src/anki-integration.test.ts | 1 + src/anki-integration.ts | 43 ++++- .../card-creation-manual-update.test.ts | 6 +- .../card-creation-sentence-media.test.ts | 3 +- src/anki-integration/card-creation.test.ts | 24 +-- src/anki-integration/card-creation.ts | 6 +- .../field-grouping-merge.test.ts | 143 +++++++++++++++++ src/anki-integration/field-grouping-merge.ts | 147 ++++++++++++++++++ .../field-grouping-workflow.test.ts | 2 +- .../field-grouping-workflow.ts | 4 +- src/anki-integration/field-grouping.test.ts | 32 ++-- src/anki-integration/field-grouping.ts | 19 ++- .../note-update-workflow.test.ts | 12 +- src/anki-integration/note-update-workflow.ts | 12 +- src/anki-integration/runtime.ts | 8 + src/config/config.test.ts | 42 +++++ .../definitions/defaults-integrations.ts | 5 + .../definitions/options-integrations.ts | 22 +++ src/config/definitions/runtime-options.ts | 17 ++ src/config/definitions/template-sections.ts | 2 +- src/config/resolve/anki-connect.ts | 2 + src/config/resolve/anki-connect/initialize.ts | 6 + src/config/resolve/anki-connect/senren.ts | 34 ++++ src/config/settings/registry.test.ts | 9 +- src/config/settings/registry.ts | 12 +- src/core/services/config-hot-reload.ts | 1 + .../runtime/config-hot-reload-handlers.ts | 3 + src/renderer/index.html | 4 +- src/shared/ipc/validators.ts | 1 + src/types/anki.ts | 5 + src/types/config.ts | 5 + src/types/runtime-options.ts | 1 + 38 files changed, 595 insertions(+), 94 deletions(-) create mode 100644 changes/senren-field-grouping.md create mode 100644 src/config/resolve/anki-connect/senren.ts diff --git a/changes/senren-field-grouping.md b/changes/senren-field-grouping.md new file mode 100644 index 00000000..6c9646aa --- /dev/null +++ b/changes/senren-field-grouping.md @@ -0,0 +1,5 @@ +type: added +area: anki + +- Senren note type support for duplicate-card field grouping: enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, with grouped sentence, furigana, audio, picture, and miscInfo entries. +- Senren field grouping supports the same auto/manual/disabled modes as Kiku, including the manual merge modal, and is mutually exclusive with Kiku (only one can be enabled at a time). diff --git a/config.example.jsonc b/config.example.jsonc index 1408eab7..b5056fd3 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -523,7 +523,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. // Shared AI provider transport settings are read from top-level ai and typically require restart. // Most other AnkiConnect settings still require restart. // ========================================== @@ -606,6 +606,11 @@ "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false }, // Is kiku setting. + "isSenren": { + "enabled": false, // Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled. Values: true | false + "fieldGrouping": "auto", // Senren duplicate-card field grouping mode (scene switching). Values: auto | manual | disabled + "deleteDuplicateInAuto": true // When Senren field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false + }, // Is senren setting. "lapisKiku": { "wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none } // Lapis kiku setting. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index d64c8c02..8eb3b73d 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -304,9 +304,9 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that `click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag. -## Field Grouping (Kiku) +## Field Grouping (Kiku/Senren) -When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields. +When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching). ```jsonc "ankiConnect": { @@ -318,6 +318,18 @@ When you mine the same word multiple times, SubMiner can merge the cards instead } ``` +For Senren note types, enable `isSenren` instead. Kiku and Senren write incompatible markup into the same fields, so only one can be enabled at a time; if both are enabled, Kiku wins and a config warning is emitted. + +```jsonc +"ankiConnect": { + "isSenren": { + "enabled": true, + "fieldGrouping": "auto", // "auto" (default), "manual", or "disabled" + "deleteDuplicateInAuto": true // delete new card after auto-merge + } +} +``` + ### Modes **Disabled** (`"disabled"`): No duplicate detection. Each card is independent. @@ -333,9 +345,12 @@ When you mine the same word multiple times, SubMiner can merge the cards instead | Sentence | Both cards' sentences kept as grouped entries | | Audio | Both cards' `[sound:...]` entries kept | | Image | Both cards' images kept | +| MiscInfo | Both cards' source info kept as grouped entries | Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate. +The merge markup depends on the note type. Kiku entries are wrapped in `` spans ordered newest first. Senren entries follow the [scene switching](https://github.com/BrenoAqua/Senren/blob/main/docs/scene_switching.md) format: sentence, sentenceFurigana, and miscInfo entries are appended as `` spans, audio and pictures are appended positionally, and the number of sentenceAudio entries drives Senren's scene count. Ungrouped legacy content is wrapped into a group span on first merge, and existing numbered `groupN` spans in miscInfo are preserved. + ### Keyboard Shortcuts in the Modal | Key | Action | diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 6726c5b3..6abfb557 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -148,9 +148,9 @@ The configuration file includes several main sections: - [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing - [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media -- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types +- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis/Senren note types - [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting -- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging +- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Senren duplicate card merging **External Integrations** @@ -1051,6 +1051,7 @@ This example is intentionally compact. The option table below documents availabl | `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time, `%T`=time with milliseconds, `
`=newline | | `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`) | +| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. | `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. @@ -1080,6 +1081,7 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La - Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits. - When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`. - `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes). +- For [Senren](https://github.com/BrenoAqua/Senren) note types, enable `isSenren` instead of `isKiku`. Duplicate merges then use Senren's scene-switching markup (including grouped `miscInfo` entries), and `isSenren.fieldGrouping` supports the same three modes (default: `auto`). Kiku and Senren are mutually exclusive; if both are enabled, Kiku wins and Senren is turned off with a config warning. - `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled. ### Word Card Type diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 08c40957..a9065149 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -72,17 +72,17 @@ After adding a word via Yomitan, press the audio card shortcut (`Ctrl/Cmd+Shift+ Audio card marking uses the same `ankiConnect.isLapis.sentenceCardModel` note type as sentence cards. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup. ::: -### Field Grouping (Kiku) +### Field Grouping (Kiku/Senren) -If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and similar note types that support grouped fields. +If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) note types that support grouped fields (Senren calls it scene switching). 1. You add a word via Yomitan. 2. SubMiner detects the new card and checks if a card with the same expression already exists. -3. If a duplicate is found (this requires `ankiConnect.isKiku.fieldGrouping` to be set to `"auto"` or `"manual"`; it defaults to `"disabled"`): - - **Auto mode** (`ankiConnect.isKiku.fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted. - - **Manual mode** (`ankiConnect.isKiku.fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming. +3. If a duplicate is found (this requires Kiku or Senren to be enabled with a field grouping mode of `"auto"` or `"manual"`): + - **Auto mode**: Merges automatically. Both sentences, audio clips, images, and source info are combined into the existing card. The duplicate is optionally deleted. + - **Manual mode**: A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming. -See [Anki Integration - Field Grouping](/anki-integration#field-grouping-kiku) for configuration options, merge behavior, and modal keyboard shortcuts. +See [Anki Integration - Field Grouping](/anki-integration#field-grouping-kiku-senren) for configuration options, merge behavior, and modal keyboard shortcuts. ## Overlay Model diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 1408eab7..b5056fd3 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -523,7 +523,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. // Shared AI provider transport settings are read from top-level ai and typically require restart. // Most other AnkiConnect settings still require restart. // ========================================== @@ -606,6 +606,11 @@ "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false }, // Is kiku setting. + "isSenren": { + "enabled": false, // Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled. Values: true | false + "fieldGrouping": "auto", // Senren duplicate-card field grouping mode (scene switching). Values: auto | manual | disabled + "deleteDuplicateInAuto": true // When Senren field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false + }, // Is senren setting. "lapisKiku": { "wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none } // Lapis kiku setting. diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index b4bf358d..4182cd12 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -153,6 +153,7 @@ function createFieldGroupingMergeCollaborator(options?: { getEffectiveSentenceCardConfig: () => ({ sentenceField: 'Sentence', audioField: 'SentenceAudio', + fieldGroupingProvider: 'kiku' as const, }), getCurrentSubtitleText: () => options?.currentSubtitleText, resolveFieldName, diff --git a/src/anki-integration.ts b/src/anki-integration.ts index fcc118e1..d40f56c1 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -835,6 +835,19 @@ export class AnkiIntegration { }; } + private getSenrenConfig(): { + enabled: boolean; + fieldGrouping?: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto?: boolean; + } { + const senren = this.config.isSenren; + return { + enabled: senren?.enabled === true, + fieldGrouping: senren?.fieldGrouping, + deleteDuplicateInAuto: senren?.deleteDuplicateInAuto, + }; + } + private getEffectiveSentenceCardConfig(): { model?: string; sentenceField: string; @@ -843,10 +856,27 @@ export class AnkiIntegration { kikuEnabled: boolean; kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; kikuDeleteDuplicateInAuto: boolean; + senrenEnabled: boolean; + fieldGroupingProvider: 'kiku' | 'senren' | null; + fieldGroupingMode: 'auto' | 'manual' | 'disabled'; + fieldGroupingDeleteDuplicateInAuto: boolean; wordCardKind: WordCardKind; } { const lapis = this.getLapisConfig(); const kiku = this.getKikuConfig(); + const senren = this.getSenrenConfig(); + + const kikuFieldGrouping = (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled'; + const senrenFieldGrouping = (senren.fieldGrouping || 'auto') as 'auto' | 'manual' | 'disabled'; + // Kiku and Senren are mutually exclusive; config resolution enforces it, and + // Kiku wins here too in case a runtime patch re-enables both. + const fieldGroupingProvider = kiku.enabled ? 'kiku' : senren.enabled ? 'senren' : null; + const fieldGroupingMode = + fieldGroupingProvider === 'kiku' + ? kikuFieldGrouping + : fieldGroupingProvider === 'senren' + ? senrenFieldGrouping + : 'disabled'; return { model: lapis.sentenceCardModel, @@ -854,8 +884,15 @@ export class AnkiIntegration { audioField: 'SentenceAudio', lapisEnabled: lapis.enabled, kikuEnabled: kiku.enabled, - kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled', + kikuFieldGrouping, kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false, + senrenEnabled: senren.enabled, + fieldGroupingProvider, + fieldGroupingMode, + fieldGroupingDeleteDuplicateInAuto: + fieldGroupingProvider === 'senren' + ? senren.deleteDuplicateInAuto !== false + : kiku.deleteDuplicateInAuto !== false, wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind), }; } @@ -874,7 +911,7 @@ export class AnkiIntegration { private async processNewCard( noteId: number, - options?: { skipKikuFieldGrouping?: boolean }, + options?: { skipFieldGrouping?: boolean }, ): Promise { await this.noteUpdateWorkflow.execute(noteId, options); } @@ -1496,7 +1533,7 @@ export class AnkiIntegration { trackedDuplicateNoteIdsBeforeCreate: Set, ): boolean { const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); - if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') { + if (sentenceCardConfig.fieldGroupingMode === 'disabled') { return false; } diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index 59cbe267..537cd98f 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -124,8 +124,7 @@ function createManualUpdateService(overrides: Partial = {}): { audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -208,8 +207,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), setCardTypeFields, }); diff --git a/src/anki-integration/card-creation-sentence-media.test.ts b/src/anki-integration/card-creation-sentence-media.test.ts index d8bb27cc..b8c4604c 100644 --- a/src/anki-integration/card-creation-sentence-media.test.ts +++ b/src/anki-integration/card-creation-sentence-media.test.ts @@ -117,8 +117,7 @@ test('sentence card writes generated audio only to sentence audio field', async audioField: 'SentenceAudio', lapisEnabled: true, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index 19159887..67857d03 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -69,8 +69,7 @@ test('CardCreationService counts locally created sentence cards', async () => { audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -168,8 +167,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -267,8 +265,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws', audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -387,8 +384,7 @@ test('CardCreationService uses stream-open-filename for remote media generation' audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -490,8 +486,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -629,8 +624,7 @@ test('CardCreationService queues YouTube media when required cache is not ready' audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'disabled', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -728,8 +722,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'manual', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, @@ -817,8 +810,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', - kikuDeleteDuplicateInAuto: false, + fieldGroupingMode: 'manual', }), getFallbackDurationSeconds: () => 10, appendKnownWordsFromNoteInfo: () => undefined, diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index abfe41f3..5630d712 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -132,8 +132,7 @@ interface CardCreationDeps { audioField: string; lapisEnabled: boolean; kikuEnabled: boolean; - kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; - kikuDeleteDuplicateInAuto: boolean; + fieldGroupingMode: 'auto' | 'manual' | 'disabled'; wordCardKind?: WordCardKind; }; getFallbackDurationSeconds: () => number; @@ -632,8 +631,7 @@ export class CardCreationService { ).trim(); let duplicateNoteIds: number[] = []; if ( - sentenceCardConfig.kikuEnabled && - sentenceCardConfig.kikuFieldGrouping !== 'disabled' && + sentenceCardConfig.fieldGroupingMode !== 'disabled' && pendingExpressionText && this.deps.findDuplicateNoteIds ) { diff --git a/src/anki-integration/field-grouping-merge.test.ts b/src/anki-integration/field-grouping-merge.test.ts index 1a1494e3..4e3fdb56 100644 --- a/src/anki-integration/field-grouping-merge.test.ts +++ b/src/anki-integration/field-grouping-merge.test.ts @@ -26,6 +26,7 @@ function createCollaborator( miscInfoValue?: string; }; warnings?: Array<{ fieldName: string; reason: string; detail?: string }>; + fieldGroupingProvider?: 'kiku' | 'senren' | null; } = {}, ) { const warnings = options.warnings ?? []; @@ -46,6 +47,7 @@ function createCollaborator( getEffectiveSentenceCardConfig: () => ({ sentenceField: 'Sentence', audioField: 'SentenceAudio', + fieldGroupingProvider: options.fieldGroupingProvider ?? 'kiku', }), getCurrentSubtitleText: () => options.currentSubtitleText, resolveFieldName, @@ -251,6 +253,147 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen assert.equal(withMedia.MiscInfo, 'generated misc'); }); +test('computeFieldGroupingMergedFields merges Senren notes into scene-switching markup', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + word: '語', + sentence: '', + sentenceAudio: '[sound:original.opus]', + picture: '', + miscInfo: 'Show EP1 (0:01:00)', + }), + makeNote(200, { + word: '語', + sentence: '', + sentenceAudio: '[sound:new.opus]', + picture: '', + miscInfo: 'Show EP2 (0:02:00)', + }), + false, + ); + + assert.equal( + merged.sentence, + '' + + '', + ); + assert.equal(merged.sentenceAudio, '[sound:original.opus][sound:new.opus]'); + assert.equal(merged.picture, ''); + assert.equal( + merged.miscInfo, + 'Show EP1 (0:01:00)Show EP2 (0:02:00)', + ); +}); + +test('Senren merge wraps ungrouped legacy content and preserves numbered groups', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + sentence: 'plain legacy sentence', + sentenceAudio: '[sound:a.opus][sound:b.opus]', + miscInfo: 'pinned stray text', + }), + makeNote(200, { + sentence: 'new sentence', + sentenceAudio: '[sound:c.opus]', + miscInfo: 'new misc', + }), + false, + ); + + assert.equal( + merged.sentence, + 'plain legacy sentencenew sentence', + ); + assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus]'); + assert.equal( + merged.miscInfo, + 'pinnedstray text' + + 'new misc', + ); +}); + +test('Senren merge rebases numbered groups from an appended source note', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]', + miscInfo: 'keep onekeep two', + }), + makeNote(200, { + sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]', + miscInfo: 'source two', + }), + false, + ); + + assert.equal( + merged.sentenceAudio, + '[sound:keep-a.opus][sound:keep-b.opus][sound:source-a.opus][sound:source-b.opus]', + ); + assert.equal( + merged.miscInfo, + 'keep onekeep two' + + 'source two', + ); +}); + +test('Senren merge keeps ungrouped text in place around an existing group span', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + miscInfo: 'leadingmiddletrailing', + sentenceAudio: '[sound:a.opus][sound:b.opus][sound:c.opus]', + }), + makeNote(200, { + miscInfo: 'appended', + sentenceAudio: '[sound:d.opus]', + }), + false, + ); + + // Order must follow the source field, and the two ungrouped runs must stay separate. + assert.equal( + merged.miscInfo, + 'leadingmiddle' + + 'trailingappended', + ); + assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus][sound:d.opus]'); +}); + +test('Senren merge closes unclosed group spans so later scenes stay siblings', async () => { + const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { miscInfo: 'ab' }), + makeNote(200, { miscInfo: 'next' }), + false, + ); + + assert.equal( + merged.miscInfo, + 'abnext', + ); + const openTags = merged.miscInfo!.match(//g)?.length ?? 0; + assert.equal(openTags, closeTags); +}); + test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => { const { collaborator } = createCollaborator(); diff --git a/src/anki-integration/field-grouping-merge.ts b/src/anki-integration/field-grouping-merge.ts index d7def9ef..a79f3091 100644 --- a/src/anki-integration/field-grouping-merge.ts +++ b/src/anki-integration/field-grouping-merge.ts @@ -19,6 +19,7 @@ interface FieldGroupingMergeDeps { getEffectiveSentenceCardConfig: () => { sentenceField: string; audioField: string; + fieldGroupingProvider: 'kiku' | 'senren' | null; }; getCurrentSubtitleText: () => string | undefined; resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; @@ -78,6 +79,13 @@ export class FieldGroupingMergeCollaborator { const configuredWordField = getConfiguredWordFieldName(config); const groupableFields = this.getGroupableFieldNames(); const keepFieldNames = Object.keys(keepNoteInfo.fields); + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const senrenSourceSceneOffset = + sentenceCardConfig.fieldGroupingProvider === 'senren' + ? this.countSenrenAudioScenes( + this.getResolvedFieldValue(keepNoteInfo, sentenceCardConfig.audioField), + ) + : 0; const sourceFields: Record = {}; const resolvedKeepFieldByPreferred = new Map(); for (const preferredFieldName of groupableFields) { @@ -162,6 +170,7 @@ export class FieldGroupingMergeCollaborator { keepNoteId, deleteNoteId, keepFieldName, + senrenSourceSceneOffset, ) : ''; continue; @@ -174,6 +183,7 @@ export class FieldGroupingMergeCollaborator { keepNoteId, deleteNoteId, keepFieldName, + senrenSourceSceneOffset, ); } else if (existingValue.trim() && newValue.trim()) { mergedFields[keepFieldName] = this.applyFieldGrouping( @@ -182,6 +192,7 @@ export class FieldGroupingMergeCollaborator { keepNoteId, deleteNoteId, keepFieldName, + senrenSourceSceneOffset, ); } else { if (!newValue.trim()) continue; @@ -342,13 +353,149 @@ export class FieldGroupingMergeCollaborator { return [...entries].sort((a, b) => b.groupId - a.groupId); } + private isSentenceAudioField(fieldName: string): boolean { + const normalized = fieldName.toLowerCase(); + const audioField = ( + this.deps.getEffectiveSentenceCardConfig().audioField || 'sentenceaudio' + ).toLowerCase(); + return normalized === 'sentenceaudio' || normalized === audioField; + } + + private isSenrenGroupOpenTag(openTag: string): boolean { + const classMatch = + openTag.match(/class\s*=\s*"([^"]*)"/i) || openTag.match(/class\s*=\s*'([^']*)'/i); + if (!classMatch) return false; + // Senren's templates match class tokens case-sensitively (/^group\d*$/). + return classMatch[1]!.split(/\s+/).some((token) => /^group\d*$/.test(token)); + } + + private countSenrenAudioScenes(value: string): number { + const soundEntries = value.match(/\[sound:[^\]]+\]/g)?.length ?? 0; + if (soundEntries > 0) return soundEntries; + return this.parseSenrenSceneEntries(value).length; + } + + private rebaseSenrenNumberedGroup(entry: string, sceneOffset: number): string { + if (sceneOffset <= 0) return entry; + + return entry.replace( + /^(\s*]*?\bclass\s*=\s*)(["'])([^"']*)\2/i, + (_match: string, prefix: string, quote: string, rawClasses: string) => { + const classes = rawClasses + .split(/(\s+)/) + .map((classToken) => { + const groupMatch = classToken.match(/^group(\d+)$/); + if (!groupMatch) return classToken; + const targetScene = Number(groupMatch[1]); + if (!Number.isSafeInteger(targetScene) || targetScene <= 0) return classToken; + return `group${targetScene + sceneOffset}`; + }) + .join(''); + return `${prefix}${quote}${classes}${quote}`; + }, + ); + } + + /** + * Splits a Senren field into ordered scene entries. Top-level + * ``/`"groupN"` spans are kept verbatim (nested markup like + * `` included); ungrouped runs are wrapped in a group + * span at their original position, because Senren discards anything outside a + * group span once scene switching activates. + */ + private parseSenrenSceneEntries(value: string): string[] { + const tokenRegex = /]*>|<\/span>/gi; + const entries: string[] = []; + const pushUngrouped = (raw: string): void => { + const text = raw.replace(//gi, ' ').trim(); + if (text) entries.push(`${text}`); + }; + let cursor = 0; + let depth = 0; + let entryStart = -1; + let match; + while ((match = tokenRegex.exec(value)) !== null) { + const token = match[0]!; + if (token[1] !== '/') { + if (depth === 0 && this.isSenrenGroupOpenTag(token)) { + pushUngrouped(value.slice(cursor, match.index)); + entryStart = match.index; + cursor = match.index; + } + depth += 1; + } else { + depth = Math.max(0, depth - 1); + if (depth === 0 && entryStart !== -1) { + const end = match.index + token.length; + entries.push(value.slice(entryStart, end)); + entryStart = -1; + cursor = end; + } + } + } + if (entryStart !== -1) { + // Unclosed group span: close every span still open (the group and any nested + // markup) so the following scenes are siblings rather than nested inside it. + entries.push(`${value.slice(entryStart)}${''.repeat(depth)}`); + } else { + pushUngrouped(value.slice(cursor)); + } + return entries; + } + + /** + * Merges two notes' field values in Senren's scene-switching format. Scenes are + * appended in order (existing first, never resorted) so indices stay aligned + * across sentence/picture/miscInfo with the sentenceAudio entries, which alone + * drive Senren's scene count. + */ + private applySenrenFieldGrouping( + existingValue: string, + newValue: string, + fieldName: string, + sourceSceneOffset: number, + ): string { + if (this.isPictureField(fieldName)) { + const tags = [...this.extractImageTags(existingValue), ...this.extractImageTags(newValue)]; + if (tags.length === 0) return existingValue || newValue; + return tags.join(''); + } + + if (this.isSentenceAudioField(fieldName)) { + const existing = existingValue.trim(); + const added = newValue.trim(); + if (!existing || !added) return existing || added; + if (!/\[sound:[^\]]+\]/.test(added)) { + this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); + } + return existing + added; + } + + const sourceEntries = this.parseSenrenSceneEntries(newValue).map((entry) => + this.rebaseSenrenNumberedGroup(entry, sourceSceneOffset), + ); + const merged = [...this.parseSenrenSceneEntries(existingValue), ...sourceEntries]; + if (merged.length === 0) return existingValue || newValue; + return merged.join(''); + } + private applyFieldGrouping( existingValue: string, newValue: string, keepGroupId: number, sourceGroupId: number, fieldName: string, + senrenSourceSceneOffset: number, ): string { + if (this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren') { + return this.applySenrenFieldGrouping( + existingValue, + newValue, + fieldName, + senrenSourceSceneOffset, + ); + } + if (this.shouldUseStrictSpanGrouping(fieldName)) { if (this.isPictureField(fieldName)) { const keepEntries = this.parsePictureEntries(existingValue, keepGroupId); diff --git a/src/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts index 71306374..a01f209c 100644 --- a/src/anki-integration/field-grouping-workflow.test.ts +++ b/src/anki-integration/field-grouping-workflow.test.ts @@ -71,7 +71,7 @@ function createWorkflowHarness() { getEffectiveSentenceCardConfig: () => ({ sentenceField: 'Sentence', audioField: 'SentenceAudio', - kikuDeleteDuplicateInAuto: true, + fieldGroupingDeleteDuplicateInAuto: true, }), getCurrentSubtitleText: () => 'subtitle-text', getFieldGroupingCallback: (): FieldGroupingCallback | null => { diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts index 6c3854f1..a0dfb669 100644 --- a/src/anki-integration/field-grouping-workflow.ts +++ b/src/anki-integration/field-grouping-workflow.ts @@ -24,7 +24,7 @@ export interface FieldGroupingWorkflowDeps { getEffectiveSentenceCardConfig: () => { sentenceField: string; audioField: string; - kikuDeleteDuplicateInAuto: boolean; + fieldGroupingDeleteDuplicateInAuto: boolean; }; getCurrentSubtitleText: () => string | undefined; getFieldGroupingCallback: @@ -75,7 +75,7 @@ export class FieldGroupingWorkflow { originalNoteId, newNoteId, this.getExpression(newNoteInfo), - sentenceCardConfig.kikuDeleteDuplicateInAuto, + sentenceCardConfig.fieldGroupingDeleteDuplicateInAuto, ); } catch (error) { this.deps.logError('Field grouping auto merge failed:', (error as Error).message); diff --git a/src/anki-integration/field-grouping.test.ts b/src/anki-integration/field-grouping.test.ts index 5d44a600..2aa770ad 100644 --- a/src/anki-integration/field-grouping.test.ts +++ b/src/anki-integration/field-grouping.test.ts @@ -21,14 +21,14 @@ function createHarness( manualHandled?: boolean; expression?: string | null; currentSentenceImageField?: string | undefined; - onProcessNewCard?: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => void; + onProcessNewCard?: (noteId: number, options?: { skipFieldGrouping?: boolean }) => void; } = {}, ) { const calls: string[] = []; const findNotesQueries: Array<{ query: string; maxRetries?: number }> = []; const noteInfoRequests: number[][] = []; const duplicateRequests: Array<{ expression: string; excludeNoteId: number }> = []; - const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = []; + const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = []; const autoCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = []; const manualCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = []; @@ -46,9 +46,8 @@ function createHarness( sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, - kikuEnabled: options.kikuEnabled ?? true, - kikuFieldGrouping: options.kikuFieldGrouping ?? 'auto', - kikuDeleteDuplicateInAuto: true, + fieldGroupingProvider: (options.kikuEnabled ?? true) ? ('kiku' as const) : null, + fieldGroupingMode: options.kikuFieldGrouping ?? 'auto', }), isUpdateInProgress: () => false, getDeck: options.deck ? () => options.deck : undefined, @@ -134,7 +133,7 @@ test('triggerFieldGroupingForLastAddedCard stops when kiku mode is disabled', as await harness.service.triggerFieldGroupingForLastAddedCard(); - assert.deepEqual(harness.calls, ['osd:Kiku mode is not enabled']); + assert.deepEqual(harness.calls, ['osd:Field grouping requires Kiku or Senren mode']); assert.equal(harness.findNotesQueries.length, 0); }); @@ -143,7 +142,7 @@ test('triggerFieldGroupingForLastAddedCard stops when field grouping is disabled await harness.service.triggerFieldGroupingForLastAddedCard(); - assert.deepEqual(harness.calls, ['osd:Kiku field grouping is disabled']); + assert.deepEqual(harness.calls, ['osd:Field grouping is disabled']); assert.equal(harness.findNotesQueries.length, 0); }); @@ -155,9 +154,8 @@ test('triggerFieldGroupingForLastAddedCard stops when an update is already in pr sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, - kikuEnabled: true, - kikuFieldGrouping: 'auto', - kikuDeleteDuplicateInAuto: true, + fieldGroupingProvider: 'kiku' as const, + fieldGroupingMode: 'auto' as const, }), isUpdateInProgress: () => true, withUpdateProgress: async () => { @@ -266,7 +264,7 @@ test('triggerFieldGroupingForLastAddedCard prefers tracked duplicate note ids be }); test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => { - const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = []; + const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = []; const harness = createHarness({ noteIds: [11], notesInfo: [ @@ -298,7 +296,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi await harness.service.triggerFieldGroupingForLastAddedCard(); - assert.deepEqual(processCalls, [{ noteId: 11, options: { skipKikuFieldGrouping: true } }]); + assert.deepEqual(processCalls, [{ noteId: 11, options: { skipFieldGrouping: true } }]); assert.deepEqual(harness.manualCalls, []); }); @@ -352,9 +350,8 @@ test('buildFieldGroupingPreview returns merged compact and full previews', async sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, - kikuEnabled: true, - kikuFieldGrouping: 'auto', - kikuDeleteDuplicateInAuto: true, + fieldGroupingProvider: 'kiku' as const, + fieldGroupingMode: 'auto' as const, }), isUpdateInProgress: () => false, withUpdateProgress: async (_message, action) => action(), @@ -417,9 +414,8 @@ test('buildFieldGroupingPreview reports missing notes cleanly', async () => { sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, - kikuEnabled: true, - kikuFieldGrouping: 'auto', - kikuDeleteDuplicateInAuto: true, + fieldGroupingProvider: 'kiku' as const, + fieldGroupingMode: 'auto' as const, }), isUpdateInProgress: () => false, withUpdateProgress: async (_message, action) => action(), diff --git a/src/anki-integration/field-grouping.ts b/src/anki-integration/field-grouping.ts index b6acb57b..ba491aa2 100644 --- a/src/anki-integration/field-grouping.ts +++ b/src/anki-integration/field-grouping.ts @@ -20,9 +20,8 @@ interface FieldGroupingDeps { sentenceField: string; audioField: string; lapisEnabled: boolean; - kikuEnabled: boolean; - kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; - kikuDeleteDuplicateInAuto: boolean; + fieldGroupingProvider: 'kiku' | 'senren' | null; + fieldGroupingMode: 'auto' | 'manual' | 'disabled'; }; isUpdateInProgress: () => boolean; getDeck?: () => string | undefined; @@ -46,7 +45,7 @@ interface FieldGroupingDeps { noteInfo: FieldGroupingNoteInfo, configuredFieldNames: (string | undefined)[], ) => boolean; - processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise; + processNewCard: (noteId: number, options?: { skipFieldGrouping?: boolean }) => Promise; getSentenceCardImageFieldName: () => string | undefined; resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; computeFieldGroupingMergedFields: ( @@ -76,12 +75,12 @@ export class FieldGroupingService { async triggerFieldGroupingForLastAddedCard(): Promise { const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); - if (!sentenceCardConfig.kikuEnabled) { - this.deps.showOsdNotification('Kiku mode is not enabled'); + if (sentenceCardConfig.fieldGroupingProvider === null) { + this.deps.showOsdNotification('Field grouping requires Kiku or Senren mode'); return; } - if (sentenceCardConfig.kikuFieldGrouping === 'disabled') { - this.deps.showOsdNotification('Kiku field grouping is disabled'); + if (sentenceCardConfig.fieldGroupingMode === 'disabled') { + this.deps.showOsdNotification('Field grouping is disabled'); return; } @@ -134,7 +133,7 @@ export class FieldGroupingService { ]) ) { await this.deps.processNewCard(noteId, { - skipKikuFieldGrouping: true, + skipFieldGrouping: true, }); } @@ -147,7 +146,7 @@ export class FieldGroupingService { const noteInfo = refreshedInfo[0]!; - if (sentenceCardConfig.kikuFieldGrouping === 'auto') { + if (sentenceCardConfig.fieldGroupingMode === 'auto') { await this.deps.handleFieldGroupingAuto( duplicateNoteId, noteId, diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 2138b538..352c6a11 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -58,7 +58,7 @@ function createWorkflowHarness() { sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: false, - kikuFieldGrouping: 'disabled' as const, + fieldGroupingMode: 'disabled' as const, }), appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined, extractFields: (fields: Record) => { @@ -151,7 +151,7 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', + fieldGroupingMode: 'manual', }); harness.deps.client.notesInfo = async () => [ @@ -184,7 +184,7 @@ test('NoteUpdateWorkflow marks the configured word card kind instead of word-and sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', + fieldGroupingMode: 'manual', wordCardKind: 'click', }); harness.deps.client.notesInfo = async () => @@ -220,7 +220,7 @@ test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'manual', + fieldGroupingMode: 'manual', wordCardKind: 'none', }); harness.deps.client.notesInfo = async () => @@ -275,7 +275,7 @@ test('NoteUpdateWorkflow preserves explicit sentence card type during sentence e sentenceField: 'Sentence', lapisEnabled: true, kikuEnabled: false, - kikuFieldGrouping: 'disabled', + fieldGroupingMode: 'disabled', }); harness.deps.client.notesInfo = async () => [ @@ -318,7 +318,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async ( sentenceField: 'Sentence', lapisEnabled: false, kikuEnabled: true, - kikuFieldGrouping: 'auto', + fieldGroupingMode: 'auto', }); harness.deps.findDuplicateNote = async () => 99; harness.deps.client.notesInfo = async () => { diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index 0348bdeb..b17df0b2 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -39,7 +39,7 @@ export interface NoteUpdateWorkflowDeps { sentenceField: string; lapisEnabled: boolean; kikuEnabled: boolean; - kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; + fieldGroupingMode: 'auto' | 'manual' | 'disabled'; wordCardKind?: WordCardKind; }; appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void; @@ -160,7 +160,7 @@ export class NoteUpdateWorkflow { return null; } - async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise { + async execute(noteId: number, options?: { skipFieldGrouping?: boolean }): Promise { this.deps.beginUpdateProgress('Updating card'); try { const notesInfoResult = await this.deps.client.notesInfo([noteId]); @@ -187,9 +187,7 @@ export class NoteUpdateWorkflow { const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const shouldRunFieldGrouping = - !options?.skipKikuFieldGrouping && - sentenceCardConfig.kikuEnabled && - sentenceCardConfig.kikuFieldGrouping !== 'disabled'; + !options?.skipFieldGrouping && sentenceCardConfig.fieldGroupingMode !== 'disabled'; let duplicateNoteId: number | null = null; if (shouldRunFieldGrouping && hasExpressionText) { duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo); @@ -345,7 +343,7 @@ export class NoteUpdateWorkflow { noteInfoForGrouping = refreshedInfo[0]!; } - if (sentenceCardConfig.kikuFieldGrouping === 'auto') { + if (sentenceCardConfig.fieldGroupingMode === 'auto') { await this.deps.handleFieldGroupingAuto( duplicateNoteId, noteId, @@ -354,7 +352,7 @@ export class NoteUpdateWorkflow { ); return; } - if (sentenceCardConfig.kikuFieldGrouping === 'manual') { + if (sentenceCardConfig.fieldGroupingMode === 'manual') { await this.deps.handleFieldGroupingManual( duplicateNoteId, noteId, diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts index 83be62e8..d00d9b48 100644 --- a/src/anki-integration/runtime.ts +++ b/src/anki-integration/runtime.ts @@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku, ...(config.isKiku ?? {}), }, + isSenren: { + ...DEFAULT_ANKI_CONNECT_CONFIG.isSenren, + ...(config.isSenren ?? {}), + }, lapisKiku: { ...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku, ...(config.lapisKiku ?? {}), @@ -209,6 +213,10 @@ export class AnkiIntegrationRuntime { patch.isKiku !== undefined ? { ...this.config.isKiku, ...patch.isKiku } : this.config.isKiku, + isSenren: + patch.isSenren !== undefined + ? { ...this.config.isSenren, ...patch.isSenren } + : this.config.isSenren, lapisKiku: patch.lapisKiku !== undefined ? { ...this.config.lapisKiku, ...patch.lapisKiku } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 423cef29..8620984a 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2188,6 +2188,7 @@ test('runtime options registry is centralized', () => { 'subtitle.annotation.frequency', 'anki.nPlusOneMatchMode', 'anki.kikuFieldGrouping', + 'anki.senrenFieldGrouping', ]); }); @@ -2775,6 +2776,47 @@ test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => { ); }); +test('forces Senren off when Kiku is also enabled and validates Senren fieldGrouping', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "isKiku": { "enabled": true }, + "isSenren": { "enabled": true } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + assert.equal(service.getConfig().ankiConnect.isKiku.enabled, true); + assert.equal(service.getConfig().ankiConnect.isSenren.enabled, false); + assert.ok( + service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'), + ); + + const senrenOnlyDir = makeTempDir(); + fs.writeFileSync( + path.join(senrenOnlyDir, 'config.jsonc'), + `{ + "ankiConnect": { + "isSenren": { "enabled": true, "fieldGrouping": "sometimes" } + } + }`, + 'utf-8', + ); + + const senrenOnlyService = new ConfigService(senrenOnlyDir); + assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.enabled, true); + assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.fieldGrouping, 'auto'); + assert.ok( + senrenOnlyService + .getWarnings() + .some((warning) => warning.path === 'ankiConnect.isSenren.fieldGrouping'), + ); +}); + test('accepts valid ankiConnect knownWords deck object', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 788564be..ab8f6e67 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -91,6 +91,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< fieldGrouping: 'disabled', deleteDuplicateInAuto: true, }, + isSenren: { + enabled: false, + fieldGrouping: 'auto', + deleteDuplicateInAuto: true, + }, lapisKiku: { wordCardKind: 'word-and-sentence', }, diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 9f8c3e32..c8d883eb 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -363,6 +363,28 @@ export function buildIntegrationConfigOptionRegistry( description: 'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.', }, + { + path: 'ankiConnect.isSenren.fieldGrouping', + kind: 'enum', + enumValues: ['auto', 'manual', 'disabled'], + defaultValue: defaultConfig.ankiConnect.isSenren.fieldGrouping, + description: 'Senren duplicate-card field grouping mode (scene switching).', + runtime: runtimeOptionById.get('anki.senrenFieldGrouping'), + }, + { + path: 'ankiConnect.isSenren.enabled', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.isSenren.enabled, + description: + 'Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled.', + }, + { + path: 'ankiConnect.isSenren.deleteDuplicateInAuto', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.isSenren.deleteDuplicateInAuto, + description: + 'When Senren field grouping is "auto", delete the duplicate source card after grouping completes.', + }, { path: 'ankiConnect.isLapis.enabled', kind: 'boolean', diff --git a/src/config/definitions/runtime-options.ts b/src/config/definitions/runtime-options.ts index 12a6ceb6..06335930 100644 --- a/src/config/definitions/runtime-options.ts +++ b/src/config/definitions/runtime-options.ts @@ -124,5 +124,22 @@ export function buildRuntimeOptionRegistry( }, }), }, + { + id: 'anki.senrenFieldGrouping', + path: 'ankiConnect.isSenren.fieldGrouping', + label: 'Senren Field Grouping', + scope: 'ankiConnect', + valueType: 'enum', + allowedValues: ['auto', 'manual', 'disabled'], + defaultValue: 'auto', + requiresRestart: false, + formatValueForOsd: (value) => String(value), + toAnkiPatch: (value) => ({ + isSenren: { + fieldGrouping: + value === 'auto' || value === 'manual' || value === 'disabled' ? value : 'auto', + }, + }), + }, ]; } diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 43b22c60..405e8d8b 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ title: 'AnkiConnect Integration', description: ['Automatic Anki updates and media generation options.'], notes: [ - 'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.', + 'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.', 'Shared AI provider transport settings are read from top-level ai and typically require restart.', 'Most other AnkiConnect settings still require restart.', ], diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 8ec0da31..4e216344 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -1,6 +1,7 @@ import type { ResolveContext } from './context'; import { initializeAnkiConnectResolution } from './anki-connect/initialize'; import { applyAnkiKikuResolution } from './anki-connect/kiku'; +import { applyAnkiSenrenResolution } from './anki-connect/senren'; import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku'; import { applyAnkiKnownWordsResolution } from './anki-connect/known-words'; import { applyAnkiLegacyResolution } from './anki-connect/legacy'; @@ -23,5 +24,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata); applyAnkiKnownWordsResolution(context, ankiConnect, behavior); applyAnkiKikuResolution(context); + applyAnkiSenrenResolution(context); applyAnkiLapisKikuResolution(context, ankiConnect); } diff --git a/src/config/resolve/anki-connect/initialize.ts b/src/config/resolve/anki-connect/initialize.ts index b7571b7f..2d1a8a2d 100644 --- a/src/config/resolve/anki-connect/initialize.ts +++ b/src/config/resolve/anki-connect/initialize.ts @@ -77,6 +77,12 @@ export function initializeAnkiConnectResolution( ? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) : {}), }, + isSenren: { + ...context.resolved.ankiConnect.isSenren, + ...(isObject(ankiConnect.isSenren) + ? (ankiConnect.isSenren as (typeof context.resolved)['ankiConnect']['isSenren']) + : {}), + }, lapisKiku: { ...context.resolved.ankiConnect.lapisKiku, }, diff --git a/src/config/resolve/anki-connect/senren.ts b/src/config/resolve/anki-connect/senren.ts new file mode 100644 index 00000000..950f1c58 --- /dev/null +++ b/src/config/resolve/anki-connect/senren.ts @@ -0,0 +1,34 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; + +export function applyAnkiSenrenResolution(context: ResolveContext): void { + if ( + context.resolved.ankiConnect.isSenren.fieldGrouping !== 'auto' && + context.resolved.ankiConnect.isSenren.fieldGrouping !== 'manual' && + context.resolved.ankiConnect.isSenren.fieldGrouping !== 'disabled' + ) { + context.warn( + 'ankiConnect.isSenren.fieldGrouping', + context.resolved.ankiConnect.isSenren.fieldGrouping, + DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping, + 'Expected auto, manual, or disabled.', + ); + context.resolved.ankiConnect.isSenren.fieldGrouping = + DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping; + } + + // Kiku and Senren field grouping write incompatible markup into the same note + // fields, so only one may be active; Kiku wins to preserve pre-existing setups. + if ( + context.resolved.ankiConnect.isSenren.enabled === true && + context.resolved.ankiConnect.isKiku.enabled === true + ) { + context.warn( + 'ankiConnect.isSenren.enabled', + true, + false, + 'Kiku and Senren are mutually exclusive; disable isKiku.enabled to use Senren field grouping.', + ); + context.resolved.ankiConnect.isSenren.enabled = false; + } +} diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index f965e8e3..bc8d70f0 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -298,10 +298,12 @@ test('settings registry puts feature toggles first, then other toggles alphabeti ]; assert.equal(miningSections[0], 'AnkiConnect'); - const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features'); + const kikuLapis = fields.filter( + (candidate) => candidate.section === 'Kiku/Lapis/Senren Features', + ); assert.deepEqual( - kikuLapis.slice(0, 2).map((candidate) => candidate.configPath), - ['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'], + kikuLapis.slice(0, 3).map((candidate) => candidate.configPath), + ['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'], ); }); @@ -366,6 +368,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => { 'ankiConnect.fields.miscInfo', 'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isKiku.fieldGrouping', + 'ankiConnect.isSenren.fieldGrouping', ]) { assert.equal(field(path).restartBehavior, 'hot-reload', path); } diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 9230da0c..2e5b2740 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -131,7 +131,7 @@ const SECTION_ORDER = new Map( 'AnkiConnect', 'Note Fields', 'Media Capture', - 'Kiku/Lapis Features', + 'Kiku/Lapis/Senren Features', 'Anki AI', 'AnkiConnect Proxy', 'Jimaku', @@ -163,6 +163,7 @@ const PATH_ORDER = new Map( 'ankiConnect.proxy.enabled', 'ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', + 'ankiConnect.isSenren.enabled', 'subtitleStyle.knownWordColor', 'ankiConnect.knownWords.matureThresholdDays', 'subtitleStyle.knownWordMaturityColors.new', @@ -221,6 +222,7 @@ const LABEL_OVERRIDES: Record = { 'ankiConnect.nPlusOne.enabled': 'Enabled', 'ankiConnect.isLapis.enabled': 'Enable Lapis Features', 'ankiConnect.isKiku.enabled': 'Enable Kiku Features', + 'ankiConnect.isSenren.enabled': 'Enable Senren Features', 'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type', 'stats.toggleKey': 'Toggle Stats Overlay', 'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager', @@ -251,7 +253,9 @@ const DESCRIPTION_OVERRIDES: Record = { 'ankiConnect.pollingRate': 'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.', 'ankiConnect.isKiku.enabled': - 'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.', + 'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping. Mutually exclusive with Senren.', + 'ankiConnect.isSenren.enabled': + 'Enable Senren-specific duplicate handling: field grouping merges duplicates into Senren scene-switching markup (including miscInfo grouping). Mutually exclusive with Kiku; only one can be enabled at a time.', 'ankiConnect.isLapis.enabled': 'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.', 'ankiConnect.isLapis.sentenceCardModel': @@ -407,9 +411,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s if ( path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.') || + path.startsWith('ankiConnect.isSenren.') || path.startsWith('ankiConnect.lapisKiku.') ) { - return { category: 'mining-anki', section: 'Kiku/Lapis Features' }; + return { category: 'mining-anki', section: 'Kiku/Lapis/Senren Features' }; } if (path.startsWith('ankiConnect.ai.')) { return { category: 'mining-anki', section: 'Anki AI' }; @@ -709,6 +714,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { path === 'ankiConnect.fields.miscInfo' || path === 'ankiConnect.isLapis.sentenceCardModel' || path === 'ankiConnect.isKiku.fieldGrouping' || + path === 'ankiConnect.isSenren.fieldGrouping' || path === 'ankiConnect.lapisKiku.wordCardKind' || path === 'mpv.aniskipEnabled' || path === 'mpv.aniskipButtonKey' || diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index 4da9ca27..aef02596 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [ 'ankiConnect.fields.miscInfo', 'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isKiku.fieldGrouping', + 'ankiConnect.isSenren.fieldGrouping', 'ankiConnect.lapisKiku.wordCardKind', ] as const; diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 1a74e9d1..c07e7dca 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -134,6 +134,9 @@ function buildAnkiRuntimeConfigPatch( if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) { patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping }; } + if (diff.hotReloadFields.includes('ankiConnect.isSenren.fieldGrouping')) { + patch.isSenren = { fieldGrouping: config.ankiConnect.isSenren.fieldGrouping }; + } if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) { patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind }; } diff --git a/src/renderer/index.html b/src/renderer/index.html index 49c09027..f2279ad1 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -205,8 +205,8 @@
A card with the same expression already exists. Select which card to keep. The other - card's content will be merged using Kiku field grouping. You can choose whether to - delete the duplicate. + card's content will be merged using field grouping. You can choose whether to delete + the duplicate.
diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 6487e3a3..e9a8601b 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -59,6 +59,7 @@ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [ 'subtitle.annotation.jlpt', 'subtitle.annotation.frequency', 'anki.kikuFieldGrouping', + 'anki.senrenFieldGrouping', 'anki.nPlusOneMatchMode', ]; diff --git a/src/types/anki.ts b/src/types/anki.ts index 22adea92..8c49e2d7 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -124,6 +124,11 @@ export interface AnkiConnectConfig { fieldGrouping?: 'auto' | 'manual' | 'disabled'; deleteDuplicateInAuto?: boolean; }; + isSenren?: { + enabled?: boolean; + fieldGrouping?: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto?: boolean; + }; lapisKiku?: { wordCardKind?: WordCardKind; }; diff --git a/src/types/config.ts b/src/types/config.ts index 406f63e6..3f4cea10 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -285,6 +285,11 @@ export interface ResolvedConfig { fieldGrouping: 'auto' | 'manual' | 'disabled'; deleteDuplicateInAuto: boolean; }; + isSenren: { + enabled: boolean; + fieldGrouping: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto: boolean; + }; lapisKiku: { wordCardKind: WordCardKind; }; diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts index 9c2b423a..63160e31 100644 --- a/src/types/runtime-options.ts +++ b/src/types/runtime-options.ts @@ -6,6 +6,7 @@ export type RuntimeOptionId = | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency' | 'anki.kikuFieldGrouping' + | 'anki.senrenFieldGrouping' | 'anki.nPlusOneMatchMode'; export type RuntimeOptionScope = 'ankiConnect' | 'subtitle';