mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-30 07:21:32 -07:00
feat(anki): add configurable word card type for Kiku/Lapis
- Add `ankiConnect.lapisKiku.wordCardKind` setting to choose the card-type flag SubMiner marks on mined word cards: word-and-sentence (default), click, sentence, audio, or none - Support IsClickCard, which SubMiner previously never set - Clear other card-type flags whenever one is set, so a note can't claim two card types at once - Extract card-kind flag resolution into new card-kinds.ts module - Hot-reload the new setting alongside existing Kiku/Lapis config - Update docs-site and config.example.jsonc
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
type: added
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Added `ankiConnect.lapisKiku.wordCardKind` (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards: `word-and-sentence` (default, unchanged behavior), `click`, `sentence`, `audio`, or `none` to leave the flags alone. Requested by Kiku users who only want `IsClickCard` set.
|
||||||
|
- Word cards can now be flagged as Kiku click cards (`IsClickCard`), which SubMiner previously never set.
|
||||||
|
- Setting a card-type flag now clears `IsClickCard` alongside the other card-type flags, so a note can no longer claim two card types at once.
|
||||||
@@ -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, and isKiku.fieldGrouping 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, 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.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"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.
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards: word-and-sentence (IsWordAndSentenceCard), click (IsClickCard), sentence (IsSentenceCard), audio (IsAudioCard), or none to leave the flags untouched. Only applies when isKiku.enabled or isLapis.enabled; the other card-type flags are cleared so a note never claims two kinds. Cards mined with the dedicated sentence/audio actions keep their own flag. Values: word-and-sentence | click | sentence | audio | none
|
||||||
|
} // Lapis kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -289,6 +289,21 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
|
|||||||
|
|
||||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||||
|
|
||||||
|
## Word Card Type (Kiku/Lapis)
|
||||||
|
|
||||||
|
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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)
|
||||||
|
|
||||||
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 like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
||||||
@@ -313,11 +328,11 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
|
|
||||||
### What Gets Merged
|
### What Gets Merged
|
||||||
|
|
||||||
| Field | Merge behavior |
|
| Field | Merge behavior |
|
||||||
| -------- | ---------------------------------------- |
|
| -------- | --------------------------------------------- |
|
||||||
| 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 |
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
+72
-54
@@ -398,30 +398,30 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||||
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
||||||
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||||
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
||||||
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
|
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
|
||||||
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
|
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
|
||||||
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||||
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
|
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
|
||||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
|
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
|
||||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||||
|
|
||||||
Subtitle CSS custom properties:
|
Subtitle CSS custom properties:
|
||||||
|
|
||||||
@@ -555,11 +555,11 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
||||||
|
|
||||||
@@ -1043,7 +1043,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||||
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
||||||
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
||||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||||
@@ -1069,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"fieldGrouping": "manual",
|
"fieldGrouping": "manual",
|
||||||
"deleteDuplicateInAuto": true
|
"deleteDuplicateInAuto": true
|
||||||
|
},
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1077,6 +1080,21 @@ 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).
|
||||||
|
- `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
|
||||||
|
|
||||||
|
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
|
||||||
|
|
||||||
|
| Value | Flag set |
|
||||||
|
| ----------------------------- | ----------------------- |
|
||||||
|
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
|
||||||
|
| `click` | `IsClickCard` |
|
||||||
|
| `sentence` | `IsSentenceCard` |
|
||||||
|
| `audio` | `IsAudioCard` |
|
||||||
|
| `none` | none; flags left as-is |
|
||||||
|
|
||||||
|
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
|
||||||
|
|
||||||
### N+1 Word Highlighting
|
### N+1 Word Highlighting
|
||||||
|
|
||||||
@@ -1167,10 +1185,10 @@ TsukiHime subtitle search works out of the box and needs no account or API key.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
|
||||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
||||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
||||||
|
|
||||||
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
||||||
|
|
||||||
@@ -1228,17 +1246,17 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
||||||
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||||
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
||||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
||||||
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
||||||
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
||||||
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
||||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
||||||
|
|
||||||
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
|
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
|
||||||
|
|
||||||
@@ -1539,18 +1557,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
|
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
|
||||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||||
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
||||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||||
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
||||||
|
|
||||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||||
|
|
||||||
|
|||||||
@@ -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, and isKiku.fieldGrouping 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, 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.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"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.
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards: word-and-sentence (IsWordAndSentenceCard), click (IsClickCard), sentence (IsSentenceCard), audio (IsAudioCard), or none to leave the flags untouched. Only applies when isKiku.enabled or isLapis.enabled; the other card-type flags are cleared so a note never claims two kinds. Cards mined with the dedicated sentence/audio actions keep their own flag. Values: word-and-sentence | click | sentence | audio | none
|
||||||
|
} // Lapis kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
+7
-74
@@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
AnkiConnectConfig,
|
AnkiConnectConfig,
|
||||||
|
type CardKind,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
|
type WordCardKind,
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
withUpdateProgress,
|
withUpdateProgress,
|
||||||
UiFeedbackState,
|
UiFeedbackState,
|
||||||
} from './anki-integration/ui-feedback';
|
} from './anki-integration/ui-feedback';
|
||||||
|
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
|
||||||
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||||
import { PollingRunner } from './anki-integration/polling';
|
import { PollingRunner } from './anki-integration/polling';
|
||||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||||
@@ -83,8 +86,6 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -840,6 +841,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind: WordCardKind;
|
||||||
} {
|
} {
|
||||||
const lapis = this.getLapisConfig();
|
const lapis = this.getLapisConfig();
|
||||||
const kiku = this.getKikuConfig();
|
const kiku = this.getKikuConfig();
|
||||||
@@ -852,6 +854,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: kiku.enabled,
|
kikuEnabled: kiku.enabled,
|
||||||
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
||||||
|
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,79 +1318,9 @@ export class AnkiIntegration {
|
|||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: CardKind,
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
const audioFlagNames = ['IsAudioCard'];
|
applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
|
||||||
|
this.resolveFieldName(availableFieldNames, preferredName),
|
||||||
if (cardKind === 'word-and-sentence') {
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
|
||||||
if (!wordAndSentenceFlag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardKind === 'sentence') {
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = 'x';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== sentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedAudioFlags = Array.from(
|
|
||||||
new Set(
|
|
||||||
audioFlagNames
|
|
||||||
.map((name) => this.resolveFieldName(availableFieldNames, name))
|
|
||||||
.filter((name): name is string => Boolean(name)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const audioFlagName = resolvedAudioFlags[0] || null;
|
|
||||||
if (audioFlagName) {
|
|
||||||
updatedFields[audioFlagName] = 'x';
|
|
||||||
}
|
|
||||||
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
|
|
||||||
updatedFields[extraAudioFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showNotification(
|
private async showNotification(
|
||||||
|
|||||||
@@ -4,29 +4,23 @@ import test from 'node:test';
|
|||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig, CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
if (cardKind !== 'word-and-sentence') return;
|
applyCardKindFlagFields(
|
||||||
|
updatedFields,
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
cardKind,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
@@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
|||||||
kikuFieldGrouping: 'disabled',
|
kikuFieldGrouping: 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: false,
|
kikuDeleteDuplicateInAuto: false,
|
||||||
}),
|
}),
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.updateLastAddedFromClipboard('字幕');
|
await service.updateLastAddedFromClipboard('字幕');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
resolveAudioStreamIndexForMediaGeneration,
|
resolveAudioStreamIndexForMediaGeneration,
|
||||||
type MediaGenerationInputResolverOptions,
|
type MediaGenerationInputResolverOptions,
|
||||||
} from './media-source';
|
} from './media-source';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||||
|
|
||||||
@@ -42,8 +42,6 @@ export interface CardCreationNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
interface CardCreationClient {
|
interface CardCreationClient {
|
||||||
addNote(
|
addNote(
|
||||||
deck: string,
|
deck: string,
|
||||||
@@ -136,6 +134,7 @@ interface CardCreationDeps {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
getFallbackDurationSeconds: () => number;
|
getFallbackDurationSeconds: () => number;
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||||
@@ -274,12 +273,9 @@ export class CardCreationService {
|
|||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
|
function resolverFor(availableFieldNames: string[]) {
|
||||||
|
return (preferredName: string): string | null =>
|
||||||
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIKU_FLAG_FIELDS = ['IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard'];
|
||||||
|
|
||||||
|
test('flags the requested card kind and clears the others', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches flag fields case-insensitively', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'word-and-sentence', resolverFor(['iswordandsentencecard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { iswordandsentencecard: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves flags untouched when the note type has no flag for a word card kind', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'click',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears stale flags for explicit mine actions even without the target flag', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'audio',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not blank the target flag it just set', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsSentenceCard: 'x' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiku/Lapis note types decide which card a note generates from mutually exclusive
|
||||||
|
* `Is...Card` flag fields. Setting one always means clearing the others.
|
||||||
|
*/
|
||||||
|
export const CARD_KIND_FLAG_FIELDS: Record<CardKind, string> = {
|
||||||
|
'word-and-sentence': 'IsWordAndSentenceCard',
|
||||||
|
click: 'IsClickCard',
|
||||||
|
sentence: 'IsSentenceCard',
|
||||||
|
audio: 'IsAudioCard',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORD_CARD_KINDS: readonly WordCardKind[] = [
|
||||||
|
'word-and-sentence',
|
||||||
|
'click',
|
||||||
|
'sentence',
|
||||||
|
'audio',
|
||||||
|
'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_WORD_CARD_KIND: WordCardKind = 'word-and-sentence';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kinds SubMiner marks on its own initiative (word cards). They are only applied
|
||||||
|
* when the note type actually carries the matching flag field, so plain note types keep
|
||||||
|
* their fields untouched.
|
||||||
|
*/
|
||||||
|
const IMPLICIT_CARD_KINDS = new Set<CardKind>(['word-and-sentence', 'click']);
|
||||||
|
|
||||||
|
export function isWordCardKind(value: unknown): value is WordCardKind {
|
||||||
|
return typeof value === 'string' && WORD_CARD_KINDS.includes(value as WordCardKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWordCardKindSetting(value: unknown): WordCardKind {
|
||||||
|
return isWordCardKind(value) ? value : DEFAULT_WORD_CARD_KIND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags `cardKind` on the note and clears every other card-kind flag it has, so the note
|
||||||
|
* never ends up claiming to be two kinds of card at once.
|
||||||
|
*/
|
||||||
|
export function applyCardKindFlagFields(
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
cardKind: CardKind,
|
||||||
|
resolveFieldName: (preferredName: string) => string | null,
|
||||||
|
): void {
|
||||||
|
const targetFlag = resolveFieldName(CARD_KIND_FLAG_FIELDS[cardKind]);
|
||||||
|
if (!targetFlag && IMPLICIT_CARD_KINDS.has(cardKind)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetFlag) {
|
||||||
|
updatedFields[targetFlag] = 'x';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [kind, flagName] of Object.entries(CARD_KIND_FLAG_FIELDS)) {
|
||||||
|
if (kind === cardKind) continue;
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== targetFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { resolveWordCardKind, type NoteFieldValueInfo } from './note-field-utils';
|
||||||
|
|
||||||
|
function kikuNote(values: Record<string, string> = {}): NoteFieldValueInfo {
|
||||||
|
const defaults: Record<string, string> = {
|
||||||
|
Expression: '単語',
|
||||||
|
Sentence: '',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsClickCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries({ ...defaults, ...values }).map(([name, value]) => [name, { value }]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('marks word-and-sentence cards by default when Kiku is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), { lapisEnabled: false, kikuEnabled: true }),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors the configured word card kind', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when neither Kiku nor Lapis is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the word card kind is "none"', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: true,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'none',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the default kind for an unrecognized setting', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'bogus' as never,
|
||||||
|
}),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the note type lacks the configured flag field', () => {
|
||||||
|
const note: NoteFieldValueInfo = {
|
||||||
|
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(note, { lapisEnabled: false, kikuEnabled: true, wordCardKind: 'click' }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves cards already mined as sentence or audio cards alone', () => {
|
||||||
|
for (const flagField of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ [flagField]: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
flagField,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-affirms the configured kind when the note already carries its flag', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'sentence',
|
||||||
|
}),
|
||||||
|
'sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overrides a differently flagged word card', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsWordAndSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { createLogger } from '../logger';
|
||||||
|
import {
|
||||||
|
CARD_KIND_FLAG_FIELDS,
|
||||||
|
DEFAULT_WORD_CARD_KIND,
|
||||||
|
resolveWordCardKindSetting,
|
||||||
|
} from './card-kinds';
|
||||||
|
|
||||||
|
const log = createLogger('anki').child('integration.note-fields');
|
||||||
|
|
||||||
export interface NoteFieldValueInfo {
|
export interface NoteFieldValueInfo {
|
||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
@@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
|
|||||||
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldMarkWordAndSentenceCard(
|
/** Flags set only by an explicit mine action; a note carrying one is not a word card. */
|
||||||
noteInfo: NoteFieldValueInfo,
|
const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
|
||||||
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
|
||||||
): boolean {
|
|
||||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
const warnedMissingFlagFields = new Set<CardKind>();
|
||||||
if (wordAndSentenceValue === null) {
|
|
||||||
return false;
|
function warnMissingFlagFieldOnce(wordCardKind: CardKind, flagField: string): void {
|
||||||
|
if (wordCardKind === DEFAULT_WORD_CARD_KIND || warnedMissingFlagFields.has(wordCardKind)) {
|
||||||
|
// The default kind is also the fallback for plain note types, so its absence is expected.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (wordAndSentenceValue.trim().length > 0) {
|
warnedMissingFlagFields.add(wordCardKind);
|
||||||
return true;
|
log.warn(
|
||||||
}
|
`Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
|
||||||
return (
|
|
||||||
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kind to flag when SubMiner fills a word card's sentence, or null to leave the
|
||||||
|
* card-kind flags alone. Kiku/Lapis only: other note types have no such fields.
|
||||||
|
*/
|
||||||
|
export function resolveWordCardKind(
|
||||||
|
noteInfo: NoteFieldValueInfo,
|
||||||
|
sentenceCardConfig: {
|
||||||
|
lapisEnabled: boolean;
|
||||||
|
kikuEnabled: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
|
},
|
||||||
|
): CardKind | null {
|
||||||
|
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = resolveWordCardKindSetting(sentenceCardConfig.wordCardKind);
|
||||||
|
if (wordCardKind === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagField = CARD_KIND_FLAG_FIELDS[wordCardKind];
|
||||||
|
const flagValue = getNoteFieldValue(noteInfo, flagField);
|
||||||
|
if (flagValue === null) {
|
||||||
|
// Note type has no flag field for the configured kind.
|
||||||
|
warnMissingFlagFieldOnce(wordCardKind, flagField);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (flagValue.trim().length > 0) {
|
||||||
|
return wordCardKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExplicitCard = EXPLICIT_CARD_FLAG_FIELDS.some(
|
||||||
|
(fieldName) =>
|
||||||
|
fieldName.toLowerCase() !== flagField.toLowerCase() && hasNoteFieldValue(noteInfo, fieldName),
|
||||||
|
);
|
||||||
|
return alreadyExplicitCard ? null : wordCardKind;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,26 +6,21 @@ import {
|
|||||||
type NoteUpdateWorkflowNoteInfo,
|
type NoteUpdateWorkflowNoteInfo,
|
||||||
} from './note-update-workflow';
|
} from './note-update-workflow';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
import type { CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
assert.equal(cardKind, 'word-and-sentence');
|
applyCardKindFlagFields(
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
updatedFields,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
cardKind,
|
||||||
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorkflowHarness() {
|
function createWorkflowHarness() {
|
||||||
@@ -79,7 +74,7 @@ function createWorkflowHarness() {
|
|||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||||
false,
|
false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
@@ -183,6 +178,73 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'click',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: 'x' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'none',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () =>
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
lapisEnabled: boolean;
|
lapisEnabled: boolean;
|
||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||||
@@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
setCardTypeFields: (
|
setCardTypeFields: (
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
) => void;
|
) => void;
|
||||||
resolveConfiguredFieldName: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
@@ -207,12 +209,9 @@ export class NoteUpdateWorkflow {
|
|||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
|||||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||||
...(config.isKiku ?? {}),
|
...(config.isKiku ?? {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
|
||||||
|
...(config.lapisKiku ?? {}),
|
||||||
|
},
|
||||||
} as AnkiConnectConfig;
|
} as AnkiConnectConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +209,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,
|
||||||
|
lapisKiku:
|
||||||
|
patch.lapisKiku !== undefined
|
||||||
|
? { ...this.config.lapisKiku, ...patch.lapisKiku }
|
||||||
|
: this.config.lapisKiku,
|
||||||
};
|
};
|
||||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||||
this.deps.onConfigChanged?.(this.config);
|
this.deps.onConfigChanged?.(this.config);
|
||||||
|
|||||||
@@ -2738,6 +2738,43 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": { "wordCardKind": "click" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
assert.equal(service.getConfig().ankiConnect.lapisKiku.wordCardKind, 'click');
|
||||||
|
assert.equal(service.getWarnings().length, 0);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"lapisKiku": { "wordCardKind": "isClickCard" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(invalidService.getConfig().ankiConnect.lapisKiku.wordCardKind, 'word-and-sentence');
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'ankiConnect.lapisKiku.wordCardKind'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
fieldGrouping: 'disabled',
|
fieldGrouping: 'disabled',
|
||||||
deleteDuplicateInAuto: true,
|
deleteDuplicateInAuto: true,
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'word-and-sentence',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
jimaku: {
|
jimaku: {
|
||||||
apiBaseUrl: 'https://jimaku.cc',
|
apiBaseUrl: 'https://jimaku.cc',
|
||||||
|
|||||||
@@ -374,6 +374,21 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||||
description: 'Note type name used by Lapis sentence cards.',
|
description: 'Note type name used by Lapis sentence cards.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['word-and-sentence', 'click', 'sentence', 'audio', 'none'],
|
||||||
|
enumLabels: {
|
||||||
|
'word-and-sentence': 'Word and sentence card (IsWordAndSentenceCard)',
|
||||||
|
click: 'Click card (IsClickCard)',
|
||||||
|
sentence: 'Sentence card (IsSentenceCard)',
|
||||||
|
audio: 'Audio card (IsAudioCard)',
|
||||||
|
none: 'Leave card type flags untouched',
|
||||||
|
},
|
||||||
|
defaultValue: defaultConfig.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
description:
|
||||||
|
'Card-type flag SubMiner marks on Kiku/Lapis word cards: word-and-sentence (IsWordAndSentenceCard), click (IsClickCard), sentence (IsSentenceCard), audio (IsAudioCard), or none to leave the flags untouched. Only applies when isKiku.enabled or isLapis.enabled; the other card-type flags are cleared so a note never claims two kinds. Cards mined with the dedicated sentence/audio actions keep their own flag.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.metadata.pattern',
|
path: 'ankiConnect.metadata.pattern',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -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, and isKiku.fieldGrouping 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, 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 { 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';
|
||||||
import { applyAnkiModernResolution } from './anki-connect/modern';
|
import { applyAnkiModernResolution } from './anki-connect/modern';
|
||||||
@@ -22,4 +23,5 @@ 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);
|
||||||
|
applyAnkiLapisKikuResolution(context, ankiConnect);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution(
|
|||||||
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...context.resolved.ankiConnect.lapisKiku,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { isWordCardKind, WORD_CARD_KINDS } from '../../../anki-integration/card-kinds';
|
||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { isObject } from '../shared';
|
||||||
|
|
||||||
|
export function applyAnkiLapisKikuResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!isObject(ankiConnect.lapisKiku)) {
|
||||||
|
if (ankiConnect.lapisKiku !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku',
|
||||||
|
ankiConnect.lapisKiku,
|
||||||
|
context.resolved.ankiConnect.lapisKiku,
|
||||||
|
'Expected object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
if (wordCardKind === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isWordCardKind(wordCardKind)) {
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind = wordCardKind;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
wordCardKind,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
`Expected one of ${WORD_CARD_KINDS.join(', ')}.`,
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
}
|
||||||
@@ -221,6 +221,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.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',
|
||||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
@@ -255,6 +256,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
|||||||
'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':
|
||||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind':
|
||||||
|
'Card-type flag marked on mined word cards: word-and-sentence sets IsWordAndSentenceCard, click sets IsClickCard, sentence sets IsSentenceCard, audio sets IsAudioCard, and none leaves the flags untouched. The other flags are cleared so a note never claims two card types. Requires Kiku or Lapis features to be enabled. Applies to Yomitan auto-enrichment, manual clipboard updates, and stats-dashboard word mining; cards mined with Mine Sentence or Mine Audio keep their own flag.',
|
||||||
'subtitleStyle.css':
|
'subtitleStyle.css':
|
||||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||||
'subtitleStyle.secondary.css':
|
'subtitleStyle.secondary.css':
|
||||||
@@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('ankiConnect.media.')) {
|
if (path.startsWith('ankiConnect.media.')) {
|
||||||
return { category: 'mining-anki', section: 'Media Capture' };
|
return { category: 'mining-anki', section: 'Media Capture' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
if (
|
||||||
|
path.startsWith('ankiConnect.isKiku.') ||
|
||||||
|
path.startsWith('ankiConnect.isLapis.') ||
|
||||||
|
path.startsWith('ankiConnect.lapisKiku.')
|
||||||
|
) {
|
||||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.ai.')) {
|
if (path.startsWith('ankiConnect.ai.')) {
|
||||||
@@ -702,6 +709,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.lapisKiku.wordCardKind' ||
|
||||||
path === 'mpv.aniskipEnabled' ||
|
path === 'mpv.aniskipEnabled' ||
|
||||||
path === 'mpv.aniskipButtonKey' ||
|
path === 'mpv.aniskipButtonKey' ||
|
||||||
path === 'stats.toggleKey' ||
|
path === 'stats.toggleKey' ||
|
||||||
|
|||||||
@@ -2454,6 +2454,80 @@ Aligned English subtitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (requests, url) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
addYomitanNote: async () => 777,
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
image: 'Picture',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
isKiku: {
|
||||||
|
enabled: true,
|
||||||
|
fieldGrouping: 'disabled',
|
||||||
|
deleteDuplicateInAuto: true,
|
||||||
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'click',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
|
|
||||||
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
|
assert.equal(fields.IsClickCard, 'x');
|
||||||
|
assert.equal(fields.IsWordAndSentenceCard, '');
|
||||||
|
assert.equal(fields.IsSentenceCard, '');
|
||||||
|
assert.equal(fields.IsAudioCard, '');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notesInfoFields: {
|
||||||
|
Expression: { value: '猫' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
Picture: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
|||||||
@@ -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.lapisKiku.wordCardKind',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function hotReloadFieldForChangedPath(path: string): string | null {
|
function hotReloadFieldForChangedPath(path: string): string | null {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
resolveSecondarySubtitleTextFromSidecar,
|
resolveSecondarySubtitleTextFromSidecar,
|
||||||
} from '../secondary-subtitle-sidecar.js';
|
} from '../secondary-subtitle-sidecar.js';
|
||||||
import {
|
import {
|
||||||
applyStatsWordAndSentenceCardFields,
|
applyStatsWordCardFields,
|
||||||
createStatsMiningContext,
|
createStatsMiningContext,
|
||||||
getStatsDirectMiningAudioFieldNames,
|
getStatsDirectMiningAudioFieldNames,
|
||||||
getStatsWordMiningAudioFieldName,
|
getStatsWordMiningAudioFieldName,
|
||||||
@@ -272,7 +272,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
|
||||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||||
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { MediaGenerator } from '../../../media-generator.js';
|
import type { MediaGenerator } from '../../../media-generator.js';
|
||||||
import type { AnkiConnectConfig } from '../../../types.js';
|
import type { AnkiConnectConfig } from '../../../types.js';
|
||||||
|
import { applyCardKindFlagFields } from '../../../anki-integration/card-kinds.js';
|
||||||
|
import { resolveWordCardKind } from '../../../anki-integration/note-field-utils.js';
|
||||||
import { createLogger } from '../../../logger.js';
|
import { createLogger } from '../../../logger.js';
|
||||||
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
|
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
|
||||||
|
|
||||||
@@ -94,20 +96,22 @@ export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig)
|
|||||||
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyStatsWordAndSentenceCardFields(
|
export function applyStatsWordCardFields(
|
||||||
fields: Record<string, string>,
|
fields: Record<string, string>,
|
||||||
noteInfo: StatsServerNoteInfo | null,
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
ankiConfig: AnkiConnectConfig,
|
ankiConfig: AnkiConnectConfig,
|
||||||
): void {
|
): void {
|
||||||
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
|
if (!noteInfo) return;
|
||||||
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
|
const cardKind = resolveWordCardKind(noteInfo, {
|
||||||
if (!wordAndSentenceFlag) return;
|
lapisEnabled: ankiConfig.isLapis?.enabled === true,
|
||||||
|
kikuEnabled: ankiConfig.isKiku?.enabled === true,
|
||||||
|
wordCardKind: ankiConfig.lapisKiku?.wordCardKind,
|
||||||
|
});
|
||||||
|
if (!cardKind) return;
|
||||||
|
|
||||||
fields[wordAndSentenceFlag] = 'x';
|
applyCardKindFlagFields(fields, cardKind, (preferredName) =>
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
resolveStatsNoteFieldName(noteInfo, preferredName),
|
||||||
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
|
);
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatsDirectMiningAudioFieldNames(
|
export function getStatsDirectMiningAudioFieldNames(
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
||||||
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
||||||
config.ankiConnect.isKiku.fieldGrouping = 'manual';
|
config.ankiConnect.isKiku.fieldGrouping = 'manual';
|
||||||
|
config.ankiConnect.lapisKiku.wordCardKind = 'click';
|
||||||
config.logging.level = 'debug';
|
config.logging.level = 'debug';
|
||||||
config.logging.rotation = 14;
|
config.logging.rotation = 14;
|
||||||
config.logging.files.mpv = true;
|
config.logging.files.mpv = true;
|
||||||
@@ -114,6 +115,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
'ankiConnect.fields.miscInfo',
|
'ankiConnect.fields.miscInfo',
|
||||||
'ankiConnect.isLapis.sentenceCardModel',
|
'ankiConnect.isLapis.sentenceCardModel',
|
||||||
'ankiConnect.isKiku.fieldGrouping',
|
'ankiConnect.isKiku.fieldGrouping',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
'logging.level',
|
'logging.level',
|
||||||
'logging.rotation',
|
'logging.rotation',
|
||||||
'logging.files.mpv',
|
'logging.files.mpv',
|
||||||
@@ -138,6 +140,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
},
|
},
|
||||||
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
|
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
|
||||||
isKiku: { fieldGrouping: 'manual' },
|
isKiku: { fieldGrouping: 'manual' },
|
||||||
|
lapisKiku: { wordCardKind: 'click' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.ok(calls.includes('invalidate:tokens'));
|
assert.ok(calls.includes('invalidate:tokens'));
|
||||||
|
|||||||
@@ -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.lapisKiku.wordCardKind')) {
|
||||||
|
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
|
||||||
|
}
|
||||||
|
|
||||||
return Object.keys(patch).length > 0 ? patch : null;
|
return Object.keys(patch).length > 0 ? patch : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import type { AiFeatureConfig } from './integrations';
|
|||||||
import type { NotificationType } from './notification';
|
import type { NotificationType } from './notification';
|
||||||
import type { NPlusOneMatchMode } from './subtitle';
|
import type { NPlusOneMatchMode } from './subtitle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card that a Kiku/Lapis note generates. The note types mark this with mutually
|
||||||
|
* exclusive `Is...Card` flag fields, so only one kind may be flagged per note.
|
||||||
|
*/
|
||||||
|
export type CardKind = 'sentence' | 'audio' | 'word-and-sentence' | 'click';
|
||||||
|
|
||||||
|
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
||||||
|
export type WordCardKind = CardKind | 'none';
|
||||||
|
|
||||||
export interface NotificationOptions {
|
export interface NotificationOptions {
|
||||||
body?: string;
|
body?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -115,4 +124,7 @@ export interface AnkiConnectConfig {
|
|||||||
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
||||||
deleteDuplicateInAuto?: boolean;
|
deleteDuplicateInAuto?: boolean;
|
||||||
};
|
};
|
||||||
|
lapisKiku?: {
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { AnkiConnectConfig } from './anki';
|
import type { AnkiConnectConfig, WordCardKind } from './anki';
|
||||||
import type {
|
import type {
|
||||||
AiConfig,
|
AiConfig,
|
||||||
AiFeatureConfig,
|
AiFeatureConfig,
|
||||||
@@ -285,6 +285,9 @@ export interface ResolvedConfig {
|
|||||||
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
deleteDuplicateInAuto: boolean;
|
deleteDuplicateInAuto: boolean;
|
||||||
};
|
};
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: WordCardKind;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
shortcuts: Required<ShortcutsConfig>;
|
shortcuts: Required<ShortcutsConfig>;
|
||||||
secondarySub: Required<SecondarySubConfig>;
|
secondarySub: Required<SecondarySubConfig>;
|
||||||
|
|||||||
Reference in New Issue
Block a user