mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-01 12:15:27 -07:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ce70dbd0
|
||
|
|
8eba6d11f9
|
||
|
|
6a6af369ec
|
||
|
|
9e1c8f91b2
|
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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": {
|
||||
@@ -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
|
||||
|
||||
**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 |
|
||||
| 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 `<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
|
||||
|
||||
| Key | Action |
|
||||
|
||||
@@ -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, `<br>`=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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -155,6 +155,7 @@ function createFieldGroupingMergeCollaborator(options?: {
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
fieldGroupingProvider: 'kiku' as const,
|
||||
}),
|
||||
getCurrentSubtitleText: () => options?.currentSubtitleText,
|
||||
resolveFieldName,
|
||||
|
||||
+40
-3
@@ -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<void> {
|
||||
await this.noteUpdateWorkflow.execute(noteId, options);
|
||||
}
|
||||
@@ -1504,7 +1541,7 @@ export class AnkiIntegration {
|
||||
trackedDuplicateNoteIdsBeforeCreate: Set<number>,
|
||||
): boolean {
|
||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||
if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,8 +124,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -208,8 +207,7 @@ test('manual clipboard word-card update uses configured fields with Lapis and Ki
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -275,8 +273,7 @@ test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -338,8 +335,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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -638,8 +637,7 @@ export class CardCreationService {
|
||||
).trim();
|
||||
let duplicateNoteIds: number[] = [];
|
||||
if (
|
||||
sentenceCardConfig.kikuEnabled &&
|
||||
sentenceCardConfig.kikuFieldGrouping !== 'disabled' &&
|
||||
sentenceCardConfig.fieldGroupingMode !== 'disabled' &&
|
||||
pendingExpressionText &&
|
||||
this.deps.findDuplicateNoteIds
|
||||
) {
|
||||
|
||||
@@ -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,7 +253,166 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
|
||||
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('Senren merge closes unclosed trailing markup before appending later scenes', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, { miscInfo: 'leading<span class="highlight">tail' }),
|
||||
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.miscInfo,
|
||||
'<span class="group">leading<span class="highlight">tail</span></span>' +
|
||||
'<span class="group">next</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('Kiku merge clears SentenceFurigana when either note lacks it', async () => {
|
||||
const { collaborator } = createCollaborator();
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
@@ -268,3 +429,21 @@ test('computeFieldGroupingMergedFields clears SentenceFurigana when either note
|
||||
|
||||
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>');
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
const resolvedKeepFieldByPreferred = new Map<string, string>();
|
||||
for (const preferredFieldName of groupableFields) {
|
||||
@@ -154,14 +162,18 @@ export class FieldGroupingMergeCollaborator {
|
||||
if (!existingValue.trim() && !newValue.trim()) continue;
|
||||
|
||||
if (keepFieldNormalized === 'sentencefurigana') {
|
||||
const hasBothValues = existingValue.trim().length > 0 && newValue.trim().length > 0;
|
||||
const usesSenrenGrouping =
|
||||
this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren';
|
||||
mergedFields[keepFieldName] =
|
||||
existingValue.trim() && newValue.trim()
|
||||
hasBothValues || usesSenrenGrouping
|
||||
? this.applyFieldGrouping(
|
||||
existingValue,
|
||||
newValue,
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepFieldName,
|
||||
senrenSourceSceneOffset,
|
||||
)
|
||||
: '';
|
||||
continue;
|
||||
@@ -174,6 +186,7 @@ export class FieldGroupingMergeCollaborator {
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepFieldName,
|
||||
senrenSourceSceneOffset,
|
||||
);
|
||||
} else if (existingValue.trim() && newValue.trim()) {
|
||||
mergedFields[keepFieldName] = this.applyFieldGrouping(
|
||||
@@ -182,6 +195,7 @@ export class FieldGroupingMergeCollaborator {
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepFieldName,
|
||||
senrenSourceSceneOffset,
|
||||
);
|
||||
} else {
|
||||
if (!newValue.trim()) continue;
|
||||
@@ -342,13 +356,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*<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)}${'</span>'.repeat(depth)}`);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -71,7 +71,7 @@ function createWorkflowHarness() {
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
kikuDeleteDuplicateInAuto: true,
|
||||
fieldGroupingDeleteDuplicateInAuto: true,
|
||||
}),
|
||||
getCurrentSubtitleText: () => 'subtitle-text',
|
||||
getFieldGroupingCallback: (): FieldGroupingCallback | null => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<void>;
|
||||
processNewCard: (noteId: number, options?: { skipFieldGrouping?: boolean }) => Promise<void>;
|
||||
getSentenceCardImageFieldName: () => string | undefined;
|
||||
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
|
||||
computeFieldGroupingMergedFields: (
|
||||
@@ -76,12 +75,12 @@ export class FieldGroupingService {
|
||||
|
||||
async triggerFieldGroupingForLastAddedCard(): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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<string, { value: string }>) => {
|
||||
@@ -136,7 +136,7 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
fieldGroupingMode: 'disabled',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
@@ -193,7 +193,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 () =>
|
||||
[
|
||||
@@ -226,7 +226,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 () =>
|
||||
@@ -262,7 +262,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 () =>
|
||||
@@ -317,7 +317,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 () =>
|
||||
[
|
||||
@@ -360,7 +360,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 () => {
|
||||
|
||||
@@ -40,7 +40,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<void> {
|
||||
async execute(noteId: number, options?: { skipFieldGrouping?: boolean }): Promise<void> {
|
||||
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);
|
||||
@@ -350,7 +348,7 @@ export class NoteUpdateWorkflow {
|
||||
noteInfoForGrouping = refreshedInfo[0]!;
|
||||
}
|
||||
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'auto') {
|
||||
await this.deps.handleFieldGroupingAuto(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
@@ -359,7 +357,7 @@ export class NoteUpdateWorkflow {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'manual') {
|
||||
await this.deps.handleFieldGroupingManual(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'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<string, number>(
|
||||
'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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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' ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -205,8 +205,8 @@
|
||||
<div id="kikuSelectionStep">
|
||||
<div class="kiku-info-text">
|
||||
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.
|
||||
</div>
|
||||
<div class="kiku-cards-container">
|
||||
<div id="kikuCard1" class="kiku-card active" tabindex="0">
|
||||
|
||||
@@ -59,6 +59,7 @@ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
|
||||
'subtitle.annotation.jlpt',
|
||||
'subtitle.annotation.frequency',
|
||||
'anki.kikuFieldGrouping',
|
||||
'anki.senrenFieldGrouping',
|
||||
'anki.nPlusOneMatchMode',
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export type RuntimeOptionId =
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency'
|
||||
| 'anki.kikuFieldGrouping'
|
||||
| 'anki.senrenFieldGrouping'
|
||||
| 'anki.nPlusOneMatchMode';
|
||||
|
||||
export type RuntimeOptionScope = 'ankiConnect' | 'subtitle';
|
||||
|
||||
Reference in New Issue
Block a user