Compare commits

..
Author SHA1 Message Date
sudacode 8eba6d11f9 fix(anki): preserve one-sided Senren furigana 2026-08-31 23:48:11 -07:00
sudacode 6a6af369ec fix(anki): sync field grouping fixtures with main 2026-08-31 23:43:20 -07:00
sudacode 9e1c8f91b2 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
2026-08-31 23:29:55 -07:00
42 changed files with 636 additions and 178 deletions
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: mining
- Multi-line copy and mining now select backward from the current subtitle in timeline order after seeking, instead of copying lines in playback encounter order.
+5
View File
@@ -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).
+6 -1
View File
@@ -523,7 +523,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // 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. // Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still 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 "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 "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is kiku setting. }, // 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": { "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 "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. } // Lapis kiku setting.
+17 -2
View File
@@ -308,9 +308,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. `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 ```jsonc
"ankiConnect": { "ankiConnect": {
@@ -322,6 +322,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 ### Modes
**Disabled** (`"disabled"`): No duplicate detection. Each card is independent. **Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
@@ -337,9 +349,12 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
| Sentence | Both cards' sentences kept as grouped entries | | Sentence | Both cards' sentences kept as grouped entries |
| Audio | Both cards' `[sound:...]` entries kept | | Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images 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. 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 `<span data-group-id="...">` 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 `<span class="group">` 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 ### Keyboard Shortcuts in the Modal
| Key | Action | | Key | Action |
+4 -2
View File
@@ -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 - [**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 - [**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 - [**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** **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, `<br>`=newline | | `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time, `%T`=time with milliseconds, `<br>`=newline |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | | `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`) | | `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. `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. 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. - 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`. - 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). - `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. - `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 ### Word Card Type
+6 -6
View File
@@ -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. 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. 1. You add a word via Yomitan.
2. SubMiner detects the new card and checks if a card with the same expression already exists. 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"`): 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** (`ankiConnect.isKiku.fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted. - **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** (`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. - **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 ## Overlay Model
+6 -1
View File
@@ -523,7 +523,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // 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. // Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still 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 "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 "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is kiku setting. }, // 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": { "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 "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. } // Lapis kiku setting.
+1 -1
View File
@@ -35,7 +35,7 @@ These work when the overlay window has focus.
| `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` | | `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` |
| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` | | `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` |
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1``9` to select the total number of subtitle lines to combine, ending at the current line and moving backward through the subtitle timeline. The current line counts toward the selected total. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin. The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1``9` to select how many recent subtitle lines to combine. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin.
## Overlay Controls ## Overlay Controls
+1
View File
@@ -155,6 +155,7 @@ function createFieldGroupingMergeCollaborator(options?: {
getEffectiveSentenceCardConfig: () => ({ getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence', sentenceField: 'Sentence',
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
fieldGroupingProvider: 'kiku' as const,
}), }),
getCurrentSubtitleText: () => options?.currentSubtitleText, getCurrentSubtitleText: () => options?.currentSubtitleText,
resolveFieldName, resolveFieldName,
+40 -3
View File
@@ -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(): { private getEffectiveSentenceCardConfig(): {
model?: string; model?: string;
sentenceField: string; sentenceField: string;
@@ -843,10 +856,27 @@ export class AnkiIntegration {
kikuEnabled: boolean; kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean; kikuDeleteDuplicateInAuto: boolean;
senrenEnabled: boolean;
fieldGroupingProvider: 'kiku' | 'senren' | null;
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
fieldGroupingDeleteDuplicateInAuto: boolean;
wordCardKind: WordCardKind; wordCardKind: WordCardKind;
} { } {
const lapis = this.getLapisConfig(); const lapis = this.getLapisConfig();
const kiku = this.getKikuConfig(); 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 { return {
model: lapis.sentenceCardModel, model: lapis.sentenceCardModel,
@@ -854,8 +884,15 @@ export class AnkiIntegration {
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: lapis.enabled, lapisEnabled: lapis.enabled,
kikuEnabled: kiku.enabled, kikuEnabled: kiku.enabled,
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled', kikuFieldGrouping,
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false, kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
senrenEnabled: senren.enabled,
fieldGroupingProvider,
fieldGroupingMode,
fieldGroupingDeleteDuplicateInAuto:
fieldGroupingProvider === 'senren'
? senren.deleteDuplicateInAuto !== false
: kiku.deleteDuplicateInAuto !== false,
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind), wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
}; };
} }
@@ -874,7 +911,7 @@ export class AnkiIntegration {
private async processNewCard( private async processNewCard(
noteId: number, noteId: number,
options?: { skipKikuFieldGrouping?: boolean }, options?: { skipFieldGrouping?: boolean },
): Promise<void> { ): Promise<void> {
await this.noteUpdateWorkflow.execute(noteId, options); await this.noteUpdateWorkflow.execute(noteId, options);
} }
@@ -1504,7 +1541,7 @@ export class AnkiIntegration {
trackedDuplicateNoteIdsBeforeCreate: Set<number>, trackedDuplicateNoteIdsBeforeCreate: Set<number>,
): boolean { ): boolean {
const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') { if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
return false; return false;
} }
@@ -124,8 +124,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
@@ -208,8 +207,7 @@ test('manual clipboard word-card update uses configured fields with Lapis and Ki
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: true, lapisEnabled: true,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
}); });
@@ -275,8 +273,7 @@ test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: true, lapisEnabled: true,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
}); });
@@ -338,8 +335,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
setCardTypeFields, setCardTypeFields,
}); });
@@ -117,8 +117,7 @@ test('sentence card writes generated audio only to sentence audio field', async
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: true, lapisEnabled: true,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
+8 -16
View File
@@ -69,8 +69,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
@@ -168,8 +167,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
@@ -267,8 +265,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
@@ -387,8 +384,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
@@ -490,8 +486,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
@@ -629,8 +624,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
@@ -728,8 +722,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'manual', fieldGroupingMode: 'manual',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
@@ -817,8 +810,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'manual', fieldGroupingMode: 'manual',
kikuDeleteDuplicateInAuto: false,
}), }),
getFallbackDurationSeconds: () => 10, getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined, appendKnownWordsFromNoteInfo: () => undefined,
+2 -4
View File
@@ -132,8 +132,7 @@ interface CardCreationDeps {
audioField: string; audioField: string;
lapisEnabled: boolean; lapisEnabled: boolean;
kikuEnabled: boolean; kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; fieldGroupingMode: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
wordCardKind?: WordCardKind; wordCardKind?: WordCardKind;
}; };
getFallbackDurationSeconds: () => number; getFallbackDurationSeconds: () => number;
@@ -638,8 +637,7 @@ export class CardCreationService {
).trim(); ).trim();
let duplicateNoteIds: number[] = []; let duplicateNoteIds: number[] = [];
if ( if (
sentenceCardConfig.kikuEnabled && sentenceCardConfig.fieldGroupingMode !== 'disabled' &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled' &&
pendingExpressionText && pendingExpressionText &&
this.deps.findDuplicateNoteIds this.deps.findDuplicateNoteIds
) { ) {
@@ -26,6 +26,7 @@ function createCollaborator(
miscInfoValue?: string; miscInfoValue?: string;
}; };
warnings?: Array<{ fieldName: string; reason: string; detail?: string }>; warnings?: Array<{ fieldName: string; reason: string; detail?: string }>;
fieldGroupingProvider?: 'kiku' | 'senren' | null;
} = {}, } = {},
) { ) {
const warnings = options.warnings ?? []; const warnings = options.warnings ?? [];
@@ -46,6 +47,7 @@ function createCollaborator(
getEffectiveSentenceCardConfig: () => ({ getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence', sentenceField: 'Sentence',
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
fieldGroupingProvider: options.fieldGroupingProvider ?? 'kiku',
}), }),
getCurrentSubtitleText: () => options.currentSubtitleText, getCurrentSubtitleText: () => options.currentSubtitleText,
resolveFieldName, resolveFieldName,
@@ -251,7 +253,148 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>'); assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
}); });
test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => { 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: '<span class="group">前<span class="highlight">語</span>後</span>',
sentenceAudio: '[sound:original.opus]',
picture: '<img src="original.webp">',
miscInfo: '<span class="group">Show EP1 (0:01:00)</span>',
}),
makeNote(200, {
word: '語',
sentence: '<span class="group">次<span class="highlight">語</span>文</span>',
sentenceAudio: '[sound:new.opus]',
picture: '<img src="new.webp">',
miscInfo: 'Show EP2 (0:02:00)',
}),
false,
);
assert.equal(
merged.sentence,
'<span class="group">前<span class="highlight">語</span>後</span>' +
'<span class="group">次<span class="highlight">語</span>文</span>',
);
assert.equal(merged.sentenceAudio, '[sound:original.opus][sound:new.opus]');
assert.equal(merged.picture, '<img src="original.webp"><img src="new.webp">');
assert.equal(
merged.miscInfo,
'<span class="group">Show EP1 (0:01:00)</span><span class="group">Show EP2 (0:02:00)</span>',
);
});
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: '<span class="group2">pinned</span> stray text',
}),
makeNote(200, {
sentence: '<span class="group">new sentence</span>',
sentenceAudio: '[sound:c.opus]',
miscInfo: '<span class="group">new misc</span>',
}),
false,
);
assert.equal(
merged.sentence,
'<span class="group">plain legacy sentence</span><span class="group">new sentence</span>',
);
assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus]');
assert.equal(
merged.miscInfo,
'<span class="group2">pinned</span><span class="group">stray text</span>' +
'<span class="group">new misc</span>',
);
});
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: '<span class="group">keep one</span><span class="group">keep two</span>',
}),
makeNote(200, {
sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]',
miscInfo: '<span class="group2">source two</span>',
}),
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,
'<span class="group">keep one</span><span class="group">keep two</span>' +
'<span class="group4">source two</span>',
);
});
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: 'leading<span class="group">middle</span>trailing',
sentenceAudio: '[sound:a.opus][sound:b.opus][sound:c.opus]',
}),
makeNote(200, {
miscInfo: '<span class="group">appended</span>',
sentenceAudio: '[sound:d.opus]',
}),
false,
);
// Order must follow the source field, and the two ungrouped runs must stay separate.
assert.equal(
merged.miscInfo,
'<span class="group">leading</span><span class="group">middle</span>' +
'<span class="group">trailing</span><span class="group">appended</span>',
);
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: '<span class="group">a<span class="highlight">b' }),
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
false,
);
assert.equal(
merged.miscInfo,
'<span class="group">a<span class="highlight">b</span></span><span class="group">next</span>',
);
const openTags = merged.miscInfo!.match(/<span\b/g)?.length ?? 0;
const closeTags = merged.miscInfo!.match(/<\/span>/g)?.length ?? 0;
assert.equal(openTags, closeTags);
});
test('Kiku merge clears SentenceFurigana when either note lacks it', async () => {
const { collaborator } = createCollaborator(); const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields( const merged = await collaborator.computeFieldGroupingMergedFields(
@@ -268,3 +411,21 @@ test('computeFieldGroupingMergedFields clears SentenceFurigana when either note
assert.equal(merged.SentenceFurigana, ''); assert.equal(merged.SentenceFurigana, '');
}); });
test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
SentenceFurigana: '',
}),
makeNote(200, {
SentenceFurigana: 'duplicate furigana',
}),
false,
);
assert.equal(merged.SentenceFurigana, '<span class="group">duplicate furigana</span>');
});
+151 -1
View File
@@ -19,6 +19,7 @@ interface FieldGroupingMergeDeps {
getEffectiveSentenceCardConfig: () => { getEffectiveSentenceCardConfig: () => {
sentenceField: string; sentenceField: string;
audioField: string; audioField: string;
fieldGroupingProvider: 'kiku' | 'senren' | null;
}; };
getCurrentSubtitleText: () => string | undefined; getCurrentSubtitleText: () => string | undefined;
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
@@ -78,6 +79,13 @@ export class FieldGroupingMergeCollaborator {
const configuredWordField = getConfiguredWordFieldName(config); const configuredWordField = getConfiguredWordFieldName(config);
const groupableFields = this.getGroupableFieldNames(); const groupableFields = this.getGroupableFieldNames();
const keepFieldNames = Object.keys(keepNoteInfo.fields); 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<string, string> = {}; const sourceFields: Record<string, string> = {};
const resolvedKeepFieldByPreferred = new Map<string, string>(); const resolvedKeepFieldByPreferred = new Map<string, string>();
for (const preferredFieldName of groupableFields) { for (const preferredFieldName of groupableFields) {
@@ -154,14 +162,18 @@ export class FieldGroupingMergeCollaborator {
if (!existingValue.trim() && !newValue.trim()) continue; if (!existingValue.trim() && !newValue.trim()) continue;
if (keepFieldNormalized === 'sentencefurigana') { if (keepFieldNormalized === 'sentencefurigana') {
const hasBothValues = existingValue.trim().length > 0 && newValue.trim().length > 0;
const usesSenrenGrouping =
this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren';
mergedFields[keepFieldName] = mergedFields[keepFieldName] =
existingValue.trim() && newValue.trim() hasBothValues || usesSenrenGrouping
? this.applyFieldGrouping( ? this.applyFieldGrouping(
existingValue, existingValue,
newValue, newValue,
keepNoteId, keepNoteId,
deleteNoteId, deleteNoteId,
keepFieldName, keepFieldName,
senrenSourceSceneOffset,
) )
: ''; : '';
continue; continue;
@@ -174,6 +186,7 @@ export class FieldGroupingMergeCollaborator {
keepNoteId, keepNoteId,
deleteNoteId, deleteNoteId,
keepFieldName, keepFieldName,
senrenSourceSceneOffset,
); );
} else if (existingValue.trim() && newValue.trim()) { } else if (existingValue.trim() && newValue.trim()) {
mergedFields[keepFieldName] = this.applyFieldGrouping( mergedFields[keepFieldName] = this.applyFieldGrouping(
@@ -182,6 +195,7 @@ export class FieldGroupingMergeCollaborator {
keepNoteId, keepNoteId,
deleteNoteId, deleteNoteId,
keepFieldName, keepFieldName,
senrenSourceSceneOffset,
); );
} else { } else {
if (!newValue.trim()) continue; if (!newValue.trim()) continue;
@@ -342,13 +356,149 @@ export class FieldGroupingMergeCollaborator {
return [...entries].sort((a, b) => b.groupId - a.groupId); 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*<span\b[^>]*?\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
* `<span class="group">`/`"groupN"` spans are kept verbatim (nested markup like
* `<span class="highlight">` 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\b[^>]*>|<\/span>/gi;
const entries: string[] = [];
const pushUngrouped = (raw: string): void => {
const text = raw.replace(/<br\s*\/?>/gi, ' ').trim();
if (text) entries.push(`<span class="group">${text}</span>`);
};
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)}${'</span>'.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( private applyFieldGrouping(
existingValue: string, existingValue: string,
newValue: string, newValue: string,
keepGroupId: number, keepGroupId: number,
sourceGroupId: number, sourceGroupId: number,
fieldName: string, fieldName: string,
senrenSourceSceneOffset: number,
): string { ): string {
if (this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren') {
return this.applySenrenFieldGrouping(
existingValue,
newValue,
fieldName,
senrenSourceSceneOffset,
);
}
if (this.shouldUseStrictSpanGrouping(fieldName)) { if (this.shouldUseStrictSpanGrouping(fieldName)) {
if (this.isPictureField(fieldName)) { if (this.isPictureField(fieldName)) {
const keepEntries = this.parsePictureEntries(existingValue, keepGroupId); const keepEntries = this.parsePictureEntries(existingValue, keepGroupId);
@@ -71,7 +71,7 @@ function createWorkflowHarness() {
getEffectiveSentenceCardConfig: () => ({ getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence', sentenceField: 'Sentence',
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
kikuDeleteDuplicateInAuto: true, fieldGroupingDeleteDuplicateInAuto: true,
}), }),
getCurrentSubtitleText: () => 'subtitle-text', getCurrentSubtitleText: () => 'subtitle-text',
getFieldGroupingCallback: (): FieldGroupingCallback | null => { getFieldGroupingCallback: (): FieldGroupingCallback | null => {
@@ -24,7 +24,7 @@ export interface FieldGroupingWorkflowDeps {
getEffectiveSentenceCardConfig: () => { getEffectiveSentenceCardConfig: () => {
sentenceField: string; sentenceField: string;
audioField: string; audioField: string;
kikuDeleteDuplicateInAuto: boolean; fieldGroupingDeleteDuplicateInAuto: boolean;
}; };
getCurrentSubtitleText: () => string | undefined; getCurrentSubtitleText: () => string | undefined;
getFieldGroupingCallback: getFieldGroupingCallback:
@@ -75,7 +75,7 @@ export class FieldGroupingWorkflow {
originalNoteId, originalNoteId,
newNoteId, newNoteId,
this.getExpression(newNoteInfo), this.getExpression(newNoteInfo),
sentenceCardConfig.kikuDeleteDuplicateInAuto, sentenceCardConfig.fieldGroupingDeleteDuplicateInAuto,
); );
} catch (error) { } catch (error) {
this.deps.logError('Field grouping auto merge failed:', (error as Error).message); this.deps.logError('Field grouping auto merge failed:', (error as Error).message);
+14 -18
View File
@@ -21,14 +21,14 @@ function createHarness(
manualHandled?: boolean; manualHandled?: boolean;
expression?: string | null; expression?: string | null;
currentSentenceImageField?: string | undefined; currentSentenceImageField?: string | undefined;
onProcessNewCard?: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => void; onProcessNewCard?: (noteId: number, options?: { skipFieldGrouping?: boolean }) => void;
} = {}, } = {},
) { ) {
const calls: string[] = []; const calls: string[] = [];
const findNotesQueries: Array<{ query: string; maxRetries?: number }> = []; const findNotesQueries: Array<{ query: string; maxRetries?: number }> = [];
const noteInfoRequests: number[][] = []; const noteInfoRequests: number[][] = [];
const duplicateRequests: Array<{ expression: string; excludeNoteId: 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 autoCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = [];
const manualCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = []; const manualCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = [];
@@ -46,9 +46,8 @@ function createHarness(
sentenceField: 'Sentence', sentenceField: 'Sentence',
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: options.kikuEnabled ?? true, fieldGroupingProvider: (options.kikuEnabled ?? true) ? ('kiku' as const) : null,
kikuFieldGrouping: options.kikuFieldGrouping ?? 'auto', fieldGroupingMode: options.kikuFieldGrouping ?? 'auto',
kikuDeleteDuplicateInAuto: true,
}), }),
isUpdateInProgress: () => false, isUpdateInProgress: () => false,
getDeck: options.deck ? () => options.deck : undefined, getDeck: options.deck ? () => options.deck : undefined,
@@ -134,7 +133,7 @@ test('triggerFieldGroupingForLastAddedCard stops when kiku mode is disabled', as
await harness.service.triggerFieldGroupingForLastAddedCard(); 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); assert.equal(harness.findNotesQueries.length, 0);
}); });
@@ -143,7 +142,7 @@ test('triggerFieldGroupingForLastAddedCard stops when field grouping is disabled
await harness.service.triggerFieldGroupingForLastAddedCard(); 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); assert.equal(harness.findNotesQueries.length, 0);
}); });
@@ -155,9 +154,8 @@ test('triggerFieldGroupingForLastAddedCard stops when an update is already in pr
sentenceField: 'Sentence', sentenceField: 'Sentence',
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, fieldGroupingProvider: 'kiku' as const,
kikuFieldGrouping: 'auto', fieldGroupingMode: 'auto' as const,
kikuDeleteDuplicateInAuto: true,
}), }),
isUpdateInProgress: () => true, isUpdateInProgress: () => true,
withUpdateProgress: async () => { 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 () => { 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({ const harness = createHarness({
noteIds: [11], noteIds: [11],
notesInfo: [ notesInfo: [
@@ -298,7 +296,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
await harness.service.triggerFieldGroupingForLastAddedCard(); await harness.service.triggerFieldGroupingForLastAddedCard();
assert.deepEqual(processCalls, [{ noteId: 11, options: { skipKikuFieldGrouping: true } }]); assert.deepEqual(processCalls, [{ noteId: 11, options: { skipFieldGrouping: true } }]);
assert.deepEqual(harness.manualCalls, []); assert.deepEqual(harness.manualCalls, []);
}); });
@@ -352,9 +350,8 @@ test('buildFieldGroupingPreview returns merged compact and full previews', async
sentenceField: 'Sentence', sentenceField: 'Sentence',
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, fieldGroupingProvider: 'kiku' as const,
kikuFieldGrouping: 'auto', fieldGroupingMode: 'auto' as const,
kikuDeleteDuplicateInAuto: true,
}), }),
isUpdateInProgress: () => false, isUpdateInProgress: () => false,
withUpdateProgress: async (_message, action) => action(), withUpdateProgress: async (_message, action) => action(),
@@ -417,9 +414,8 @@ test('buildFieldGroupingPreview reports missing notes cleanly', async () => {
sentenceField: 'Sentence', sentenceField: 'Sentence',
audioField: 'SentenceAudio', audioField: 'SentenceAudio',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, fieldGroupingProvider: 'kiku' as const,
kikuFieldGrouping: 'auto', fieldGroupingMode: 'auto' as const,
kikuDeleteDuplicateInAuto: true,
}), }),
isUpdateInProgress: () => false, isUpdateInProgress: () => false,
withUpdateProgress: async (_message, action) => action(), withUpdateProgress: async (_message, action) => action(),
+9 -10
View File
@@ -20,9 +20,8 @@ interface FieldGroupingDeps {
sentenceField: string; sentenceField: string;
audioField: string; audioField: string;
lapisEnabled: boolean; lapisEnabled: boolean;
kikuEnabled: boolean; fieldGroupingProvider: 'kiku' | 'senren' | null;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; fieldGroupingMode: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
}; };
isUpdateInProgress: () => boolean; isUpdateInProgress: () => boolean;
getDeck?: () => string | undefined; getDeck?: () => string | undefined;
@@ -46,7 +45,7 @@ interface FieldGroupingDeps {
noteInfo: FieldGroupingNoteInfo, noteInfo: FieldGroupingNoteInfo,
configuredFieldNames: (string | undefined)[], configuredFieldNames: (string | undefined)[],
) => boolean; ) => boolean;
processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise<void>; processNewCard: (noteId: number, options?: { skipFieldGrouping?: boolean }) => Promise<void>;
getSentenceCardImageFieldName: () => string | undefined; getSentenceCardImageFieldName: () => string | undefined;
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
computeFieldGroupingMergedFields: ( computeFieldGroupingMergedFields: (
@@ -76,12 +75,12 @@ export class FieldGroupingService {
async triggerFieldGroupingForLastAddedCard(): Promise<void> { async triggerFieldGroupingForLastAddedCard(): Promise<void> {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
if (!sentenceCardConfig.kikuEnabled) { if (sentenceCardConfig.fieldGroupingProvider === null) {
this.deps.showOsdNotification('Kiku mode is not enabled'); this.deps.showOsdNotification('Field grouping requires Kiku or Senren mode');
return; return;
} }
if (sentenceCardConfig.kikuFieldGrouping === 'disabled') { if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
this.deps.showOsdNotification('Kiku field grouping is disabled'); this.deps.showOsdNotification('Field grouping is disabled');
return; return;
} }
@@ -134,7 +133,7 @@ export class FieldGroupingService {
]) ])
) { ) {
await this.deps.processNewCard(noteId, { await this.deps.processNewCard(noteId, {
skipKikuFieldGrouping: true, skipFieldGrouping: true,
}); });
} }
@@ -147,7 +146,7 @@ export class FieldGroupingService {
const noteInfo = refreshedInfo[0]!; const noteInfo = refreshedInfo[0]!;
if (sentenceCardConfig.kikuFieldGrouping === 'auto') { if (sentenceCardConfig.fieldGroupingMode === 'auto') {
await this.deps.handleFieldGroupingAuto( await this.deps.handleFieldGroupingAuto(
duplicateNoteId, duplicateNoteId,
noteId, noteId,
@@ -58,7 +58,7 @@ function createWorkflowHarness() {
sentenceField: 'Sentence', sentenceField: 'Sentence',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled' as const, fieldGroupingMode: 'disabled' as const,
}), }),
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined, appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
extractFields: (fields: Record<string, { value: string }>) => { extractFields: (fields: Record<string, { value: string }>) => {
@@ -136,7 +136,7 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La
sentenceField: 'Sentence', sentenceField: 'Sentence',
lapisEnabled: true, lapisEnabled: true,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
}); });
harness.deps.client.notesInfo = async () => harness.deps.client.notesInfo = async () =>
[ [
@@ -193,7 +193,7 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
sentenceField: 'Sentence', sentenceField: 'Sentence',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'manual', fieldGroupingMode: 'manual',
}); });
harness.deps.client.notesInfo = async () => harness.deps.client.notesInfo = async () =>
[ [
@@ -226,7 +226,7 @@ test('NoteUpdateWorkflow marks the configured word card kind instead of word-and
sentenceField: 'Sentence', sentenceField: 'Sentence',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'manual', fieldGroupingMode: 'manual',
wordCardKind: 'click', wordCardKind: 'click',
}); });
harness.deps.client.notesInfo = async () => harness.deps.client.notesInfo = async () =>
@@ -262,7 +262,7 @@ test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is
sentenceField: 'Sentence', sentenceField: 'Sentence',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'manual', fieldGroupingMode: 'manual',
wordCardKind: 'none', wordCardKind: 'none',
}); });
harness.deps.client.notesInfo = async () => harness.deps.client.notesInfo = async () =>
@@ -317,7 +317,7 @@ test('NoteUpdateWorkflow preserves explicit sentence card type during sentence e
sentenceField: 'Sentence', sentenceField: 'Sentence',
lapisEnabled: true, lapisEnabled: true,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled', fieldGroupingMode: 'disabled',
}); });
harness.deps.client.notesInfo = async () => harness.deps.client.notesInfo = async () =>
[ [
@@ -360,7 +360,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
sentenceField: 'Sentence', sentenceField: 'Sentence',
lapisEnabled: false, lapisEnabled: false,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'auto', fieldGroupingMode: 'auto',
}); });
harness.deps.findDuplicateNote = async () => 99; harness.deps.findDuplicateNote = async () => 99;
harness.deps.client.notesInfo = async () => { harness.deps.client.notesInfo = async () => {
+5 -7
View File
@@ -40,7 +40,7 @@ export interface NoteUpdateWorkflowDeps {
sentenceField: string; sentenceField: string;
lapisEnabled: boolean; lapisEnabled: boolean;
kikuEnabled: boolean; kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; fieldGroupingMode: 'auto' | 'manual' | 'disabled';
wordCardKind?: WordCardKind; wordCardKind?: WordCardKind;
}; };
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void; appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
@@ -160,7 +160,7 @@ export class NoteUpdateWorkflow {
return null; return null;
} }
async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> { async execute(noteId: number, options?: { skipFieldGrouping?: boolean }): Promise<void> {
this.deps.beginUpdateProgress('Updating card'); this.deps.beginUpdateProgress('Updating card');
try { try {
const notesInfoResult = await this.deps.client.notesInfo([noteId]); const notesInfoResult = await this.deps.client.notesInfo([noteId]);
@@ -187,9 +187,7 @@ export class NoteUpdateWorkflow {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const shouldRunFieldGrouping = const shouldRunFieldGrouping =
!options?.skipKikuFieldGrouping && !options?.skipFieldGrouping && sentenceCardConfig.fieldGroupingMode !== 'disabled';
sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled';
let duplicateNoteId: number | null = null; let duplicateNoteId: number | null = null;
if (shouldRunFieldGrouping && hasExpressionText) { if (shouldRunFieldGrouping && hasExpressionText) {
duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo); duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
@@ -350,7 +348,7 @@ export class NoteUpdateWorkflow {
noteInfoForGrouping = refreshedInfo[0]!; noteInfoForGrouping = refreshedInfo[0]!;
} }
if (sentenceCardConfig.kikuFieldGrouping === 'auto') { if (sentenceCardConfig.fieldGroupingMode === 'auto') {
await this.deps.handleFieldGroupingAuto( await this.deps.handleFieldGroupingAuto(
duplicateNoteId, duplicateNoteId,
noteId, noteId,
@@ -359,7 +357,7 @@ export class NoteUpdateWorkflow {
); );
return; return;
} }
if (sentenceCardConfig.kikuFieldGrouping === 'manual') { if (sentenceCardConfig.fieldGroupingMode === 'manual') {
await this.deps.handleFieldGroupingManual( await this.deps.handleFieldGroupingManual(
duplicateNoteId, duplicateNoteId,
noteId, noteId,
+8
View File
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku, ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
...(config.isKiku ?? {}), ...(config.isKiku ?? {}),
}, },
isSenren: {
...DEFAULT_ANKI_CONNECT_CONFIG.isSenren,
...(config.isSenren ?? {}),
},
lapisKiku: { lapisKiku: {
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku, ...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
...(config.lapisKiku ?? {}), ...(config.lapisKiku ?? {}),
@@ -209,6 +213,10 @@ export class AnkiIntegrationRuntime {
patch.isKiku !== undefined patch.isKiku !== undefined
? { ...this.config.isKiku, ...patch.isKiku } ? { ...this.config.isKiku, ...patch.isKiku }
: this.config.isKiku, : this.config.isKiku,
isSenren:
patch.isSenren !== undefined
? { ...this.config.isSenren, ...patch.isSenren }
: this.config.isSenren,
lapisKiku: lapisKiku:
patch.lapisKiku !== undefined patch.lapisKiku !== undefined
? { ...this.config.lapisKiku, ...patch.lapisKiku } ? { ...this.config.lapisKiku, ...patch.lapisKiku }
+42
View File
@@ -2188,6 +2188,7 @@ test('runtime options registry is centralized', () => {
'subtitle.annotation.frequency', 'subtitle.annotation.frequency',
'anki.nPlusOneMatchMode', 'anki.nPlusOneMatchMode',
'anki.kikuFieldGrouping', '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', () => { test('accepts valid ankiConnect knownWords deck object', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
@@ -91,6 +91,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
fieldGrouping: 'disabled', fieldGrouping: 'disabled',
deleteDuplicateInAuto: true, deleteDuplicateInAuto: true,
}, },
isSenren: {
enabled: false,
fieldGrouping: 'auto',
deleteDuplicateInAuto: true,
},
lapisKiku: { lapisKiku: {
wordCardKind: 'word-and-sentence', wordCardKind: 'word-and-sentence',
}, },
@@ -363,6 +363,28 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.', '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', path: 'ankiConnect.isLapis.enabled',
kind: 'boolean', kind: 'boolean',
+17
View File
@@ -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',
},
}),
},
]; ];
} }
+1 -1
View File
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration', title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'], description: ['Automatic Anki updates and media generation options.'],
notes: [ 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.', 'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.', 'Most other AnkiConnect settings still require restart.',
], ],
+2
View File
@@ -1,6 +1,7 @@
import type { ResolveContext } from './context'; import type { ResolveContext } from './context';
import { initializeAnkiConnectResolution } from './anki-connect/initialize'; import { initializeAnkiConnectResolution } from './anki-connect/initialize';
import { applyAnkiKikuResolution } from './anki-connect/kiku'; import { applyAnkiKikuResolution } from './anki-connect/kiku';
import { applyAnkiSenrenResolution } from './anki-connect/senren';
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku'; import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words'; import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
import { applyAnkiLegacyResolution } from './anki-connect/legacy'; import { applyAnkiLegacyResolution } from './anki-connect/legacy';
@@ -23,5 +24,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata); applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior); applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context); applyAnkiKikuResolution(context);
applyAnkiSenrenResolution(context);
applyAnkiLapisKikuResolution(context, ankiConnect); applyAnkiLapisKikuResolution(context, ankiConnect);
} }
@@ -77,6 +77,12 @@ export function initializeAnkiConnectResolution(
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) ? (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: { lapisKiku: {
...context.resolved.ankiConnect.lapisKiku, ...context.resolved.ankiConnect.lapisKiku,
}, },
+34
View File
@@ -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;
}
}
+6 -3
View File
@@ -298,10 +298,12 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
]; ];
assert.equal(miningSections[0], 'AnkiConnect'); 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( assert.deepEqual(
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath), kikuLapis.slice(0, 3).map((candidate) => candidate.configPath),
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'], ['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.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping', 'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
]) { ]) {
assert.equal(field(path).restartBehavior, 'hot-reload', path); assert.equal(field(path).restartBehavior, 'hot-reload', path);
} }
+9 -3
View File
@@ -131,7 +131,7 @@ const SECTION_ORDER = new Map<string, number>(
'AnkiConnect', 'AnkiConnect',
'Note Fields', 'Note Fields',
'Media Capture', 'Media Capture',
'Kiku/Lapis Features', 'Kiku/Lapis/Senren Features',
'Anki AI', 'Anki AI',
'AnkiConnect Proxy', 'AnkiConnect Proxy',
'Jimaku', 'Jimaku',
@@ -163,6 +163,7 @@ const PATH_ORDER = new Map<string, number>(
'ankiConnect.proxy.enabled', 'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled', 'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled', 'ankiConnect.isKiku.enabled',
'ankiConnect.isSenren.enabled',
'subtitleStyle.knownWordColor', 'subtitleStyle.knownWordColor',
'ankiConnect.knownWords.matureThresholdDays', 'ankiConnect.knownWords.matureThresholdDays',
'subtitleStyle.knownWordMaturityColors.new', 'subtitleStyle.knownWordMaturityColors.new',
@@ -221,6 +222,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.nPlusOne.enabled': 'Enabled', 'ankiConnect.nPlusOne.enabled': 'Enabled',
'ankiConnect.isLapis.enabled': 'Enable Lapis Features', 'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features', 'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'ankiConnect.isSenren.enabled': 'Enable Senren Features',
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type', 'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
'stats.toggleKey': 'Toggle Stats Overlay', 'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager', 'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
@@ -251,7 +253,9 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'ankiConnect.pollingRate': 'ankiConnect.pollingRate':
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.', 'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
'ankiConnect.isKiku.enabled': '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': '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.', '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': 'ankiConnect.isLapis.sentenceCardModel':
@@ -407,9 +411,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if ( if (
path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isKiku.') ||
path.startsWith('ankiConnect.isLapis.') || path.startsWith('ankiConnect.isLapis.') ||
path.startsWith('ankiConnect.isSenren.') ||
path.startsWith('ankiConnect.lapisKiku.') 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.')) { if (path.startsWith('ankiConnect.ai.')) {
return { category: 'mining-anki', section: 'Anki AI' }; return { category: 'mining-anki', section: 'Anki AI' };
@@ -709,6 +714,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'ankiConnect.fields.miscInfo' || path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' || path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' || path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'ankiConnect.isSenren.fieldGrouping' ||
path === 'ankiConnect.lapisKiku.wordCardKind' || path === 'ankiConnect.lapisKiku.wordCardKind' ||
path === 'mpv.aniskipEnabled' || path === 'mpv.aniskipEnabled' ||
path === 'mpv.aniskipButtonKey' || path === 'mpv.aniskipButtonKey' ||
+1
View File
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'ankiConnect.fields.miscInfo', 'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping', 'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind', 'ankiConnect.lapisKiku.wordCardKind',
] as const; ] as const;
-29
View File
@@ -244,35 +244,6 @@ test('handleMultiCopyDigit copies available history and reports truncation', ()
assert.equal(osd.at(-1), 'Only 2 lines available, copied 2'); assert.equal(osd.at(-1), 'Only 2 lines available, copied 2');
}); });
test('handleMultiCopyDigit copies backward from the current subtitle after a backward seek', () => {
const copied: string[] = [];
const tracker = new SubtitleTimingTracker();
try {
tracker.recordSubtitle('A', 1, 2);
tracker.recordSubtitle('B', 3, 4);
tracker.recordSubtitle('C', 5, 6);
tracker.recordSubtitle('B', 3, 4);
const deps = {
subtitleTimingTracker: tracker,
writeClipboardText: (text: string) => copied.push(text),
showMpvOsd: () => {},
};
handleMultiCopyDigit(1, deps);
handleMultiCopyDigit(2, deps);
assert.deepEqual(copied, ['B', 'A\n\nB']);
assert.deepEqual(tracker.getRecentEntries(2), [
{ displayText: 'A', startTime: 1, endTime: 2, secondaryText: undefined },
{ displayText: 'B', startTime: 3, endTime: 4, secondaryText: undefined },
]);
} finally {
tracker.destroy();
}
});
test('handleMineSentenceDigit reports async create failures', async () => { test('handleMineSentenceDigit reports async create failures', async () => {
const osd: string[] = []; const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = []; const logs: Array<{ message: string; err: unknown }> = [];
@@ -134,6 +134,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) { if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
patch.isKiku = { fieldGrouping: config.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')) { if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) {
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind }; patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
} }
+2 -2
View File
@@ -205,8 +205,8 @@
<div id="kikuSelectionStep"> <div id="kikuSelectionStep">
<div class="kiku-info-text"> <div class="kiku-info-text">
A card with the same expression already exists. Select which card to keep. The other 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 card's content will be merged using field grouping. You can choose whether to delete
delete the duplicate. the duplicate.
</div> </div>
<div class="kiku-cards-container"> <div class="kiku-cards-container">
<div id="kikuCard1" class="kiku-card active" tabindex="0"> <div id="kikuCard1" class="kiku-card active" tabindex="0">
+1
View File
@@ -59,6 +59,7 @@ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
'subtitle.annotation.jlpt', 'subtitle.annotation.jlpt',
'subtitle.annotation.frequency', 'subtitle.annotation.frequency',
'anki.kikuFieldGrouping', 'anki.kikuFieldGrouping',
'anki.senrenFieldGrouping',
'anki.nPlusOneMatchMode', 'anki.nPlusOneMatchMode',
]; ];
+14 -43
View File
@@ -107,20 +107,28 @@ export class SubtitleTimingTracker {
} }
/** /**
* Get recent subtitle blocks in timeline order. * Get recent subtitle blocks in chronological order.
* Returns up to `count` known subtitle events ending at the current event. * Returns the last `count` subtitle events (oldest newest).
* Blocks preserve internal line breaks and are joined with blank lines. * Blocks preserve internal line breaks and are joined with blank lines.
*/ */
getRecentBlocks(count: number): string[] { getRecentBlocks(count: number): string[] {
return this.getRecentTimelineEntries(count).map((entry) => entry.displayText); if (count <= 0) return [];
if (count > this.history.length) {
count = this.history.length;
}
return this.history.slice(-count).map((entry) => entry.displayText);
} }
/** /**
* Get recent subtitle blocks with their original event timings in timeline order. * Get recent subtitle blocks with their original event timings.
* Returns up to `count` known subtitle events ending at the current event. * Returns the last `count` subtitle events (oldest newest).
*/ */
getRecentEntries(count: number): SubtitleTimingBlock[] { getRecentEntries(count: number): SubtitleTimingBlock[] {
return this.getRecentTimelineEntries(count).map((entry) => ({ if (count <= 0) return [];
if (count > this.history.length) {
count = this.history.length;
}
return this.history.slice(-count).map((entry) => ({
displayText: entry.displayText, displayText: entry.displayText,
startTime: entry.startTime, startTime: entry.startTime,
endTime: entry.endTime, endTime: entry.endTime,
@@ -136,43 +144,6 @@ export class SubtitleTimingTracker {
return lastEntry ? lastEntry.displayText : null; return lastEntry ? lastEntry.displayText : null;
} }
private getRecentTimelineEntries(count: number): HistoryEntry[] {
if (count <= 0) return [];
const currentEntry = this.history[this.history.length - 1];
if (!currentEntry) return [];
const timelineEntries: HistoryEntry[] = [];
for (const entry of this.history) {
const existingIndex = timelineEntries.findIndex((candidate) =>
this.isSameSubtitleEvent(candidate, entry),
);
if (existingIndex === -1) {
timelineEntries.push(entry);
} else {
timelineEntries[existingIndex] = entry;
}
}
timelineEntries.sort(
(left, right) => left.startTime - right.startTime || left.endTime - right.endTime,
);
const currentIndex = timelineEntries.findIndex((entry) =>
this.isSameSubtitleEvent(entry, currentEntry),
);
if (currentIndex === -1) return [];
return timelineEntries.slice(Math.max(0, currentIndex - count + 1), currentIndex + 1);
}
private isSameSubtitleEvent(left: HistoryEntry, right: HistoryEntry): boolean {
return (
left.timingKey === right.timingKey &&
left.startTime === right.startTime &&
left.endTime === right.endTime
);
}
private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null { private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null {
let bestMatch: TimingEntry | null = null; let bestMatch: TimingEntry | null = null;
let bestScore = 0; let bestScore = 0;
+5
View File
@@ -124,6 +124,11 @@ export interface AnkiConnectConfig {
fieldGrouping?: 'auto' | 'manual' | 'disabled'; fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean; deleteDuplicateInAuto?: boolean;
}; };
isSenren?: {
enabled?: boolean;
fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean;
};
lapisKiku?: { lapisKiku?: {
wordCardKind?: WordCardKind; wordCardKind?: WordCardKind;
}; };
+5
View File
@@ -285,6 +285,11 @@ export interface ResolvedConfig {
fieldGrouping: 'auto' | 'manual' | 'disabled'; fieldGrouping: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto: boolean; deleteDuplicateInAuto: boolean;
}; };
isSenren: {
enabled: boolean;
fieldGrouping: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto: boolean;
};
lapisKiku: { lapisKiku: {
wordCardKind: WordCardKind; wordCardKind: WordCardKind;
}; };
+1
View File
@@ -6,6 +6,7 @@ export type RuntimeOptionId =
| 'subtitle.annotation.jlpt' | 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency' | 'subtitle.annotation.frequency'
| 'anki.kikuFieldGrouping' | 'anki.kikuFieldGrouping'
| 'anki.senrenFieldGrouping'
| 'anki.nPlusOneMatchMode'; | 'anki.nPlusOneMatchMode';
export type RuntimeOptionScope = 'ankiConnect' | 'subtitle'; export type RuntimeOptionScope = 'ankiConnect' | 'subtitle';