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:
2026-07-30 00:45:38 -07:00
parent 1995200e76
commit d37a3eb299
31 changed files with 752 additions and 218 deletions
+6
View File
@@ -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.
+5 -2
View File
@@ -523,7 +523,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, 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.
// ========================================== // ==========================================
+19 -4
View File
@@ -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 (19) 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 (19) 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
View File
@@ -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 18760) | | `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 18760) |
| `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.
+5 -2
View File
@@ -523,7 +523,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, 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
View File
@@ -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('字幕');
+6 -10
View File
@@ -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;
} }
+64
View File
@@ -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' });
});
+63
View File
@@ -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',
);
});
+60 -15
View File
@@ -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 () =>
+7 -8
View File
@@ -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;
} }
+8
View File
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku, ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
...(config.isKiku ?? {}), ...(config.isKiku ?? {}),
}, },
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);
+37
View File
@@ -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',
+1 -1
View File
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration', title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'], description: ['Automatic Anki updates and media generation options.'],
notes: [ notes: [
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, 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.',
], ],
+2
View File
@@ -1,6 +1,7 @@
import type { ResolveContext } from './context'; import type { ResolveContext } from './context';
import { initializeAnkiConnectResolution } from './anki-connect/initialize'; import { initializeAnkiConnectResolution } from './anki-connect/initialize';
import { applyAnkiKikuResolution } from './anki-connect/kiku'; import { applyAnkiKikuResolution } from './anki-connect/kiku';
import { 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;
}
+9 -1
View File
@@ -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');
+1
View File
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'ankiConnect.fields.miscInfo', 'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping', 'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.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;
} }
+12
View File
@@ -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
View File
@@ -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>;