mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-01 00:15:28 -07:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
// 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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
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.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' ||
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user