diff --git a/changes/word-card-kind.md b/changes/word-card-kind.md new file mode 100644 index 00000000..87c9b96a --- /dev/null +++ b/changes/word-card-kind.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index b93864fd..eb9c8aae 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -523,7 +523,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, 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. // 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 "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false - } // Is kiku setting. + }, // 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. // ========================================== diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index fe7ef77a..d64c8c02 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -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. +## 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) 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 -| Field | Merge behavior | -| -------- | ---------------------------------------- | +| Field | Merge behavior | +| -------- | --------------------------------------------- | | Sentence | Both cards' sentences kept as grouped entries | -| Audio | Both cards' `[sound:...]` entries kept | -| Image | Both cards' images kept | +| Audio | Both cards' `[sound:...]` entries kept | +| Image | Both cards' images kept | Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 5e6a35e5..631991d1 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -398,30 +398,30 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `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`. | -| `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) | -| `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). | -| `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). | -| `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) | -| `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`) | +| Option | Values | Description | +| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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`. | +| `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) | +| `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). | +| `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). | +| `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) | +| `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`) | | `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`) | -| `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.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) | -| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) | -| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) | -| `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 | -| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | +| `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.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.mode` | string | `"single"` or `"banded"` (`"single"` by default) | +| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) | +| `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 | +| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | 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 | -| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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`) | -| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | +| 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 (`[]`). | +| `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"`) | 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.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.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.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. | @@ -1069,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La "enabled": true, "fieldGrouping": "manual", "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. - 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). +- `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 @@ -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.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. @@ -1228,17 +1246,17 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin } ``` -| Option | Values | Description | -| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- | -| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | -| `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.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.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.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 | +| Option | Values | Description | +| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | +| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | +| `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.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.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.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 | 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 | -| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `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=`. Leave empty to pass no profile (default `""`) | -| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | +| 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 `""`) | +| `profile` | string | mpv profile name passed as `--profile=`. Leave empty to pass no profile (default `""`) | +| `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) | -| `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`) | -| `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: `""`) | -| `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"`) | +| `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`) | +| `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: `""`) | +| `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"`) | If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index b93864fd..eb9c8aae 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -523,7 +523,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, 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. // 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 "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false - } // Is kiku setting. + }, // 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. // ========================================== diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 29bcaeb0..1263be07 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator'; import path from 'path'; import { AnkiConnectConfig, + type CardKind, KikuDuplicateCardInfo, KikuFieldGroupingChoice, KikuMergePreviewResponse, NotificationOptions, + type WordCardKind, } from './types/anki'; import { AiConfig } from './types/integrations'; import type { KnownWordMaturityTier } from './types/subtitle'; @@ -50,6 +52,7 @@ import { withUpdateProgress, UiFeedbackState, } from './anki-integration/ui-feedback'; +import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds'; import { KnownWordCacheManager } from './anki-integration/known-word-cache'; import { PollingRunner } from './anki-integration/polling'; import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy'; @@ -83,8 +86,6 @@ interface NoteInfo { fields: Record; } -type CardKind = 'sentence' | 'audio' | 'word-and-sentence'; - function trimToNonEmptyString(value: unknown): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -840,6 +841,7 @@ export class AnkiIntegration { kikuEnabled: boolean; kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; kikuDeleteDuplicateInAuto: boolean; + wordCardKind: WordCardKind; } { const lapis = this.getLapisConfig(); const kiku = this.getKikuConfig(); @@ -852,6 +854,7 @@ export class AnkiIntegration { kikuEnabled: kiku.enabled, kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled', kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false, + wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind), }; } @@ -1315,79 +1318,9 @@ export class AnkiIntegration { availableFieldNames: string[], cardKind: CardKind, ): void { - const audioFlagNames = ['IsAudioCard']; - - 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)), - ), + applyCardKindFlagFields(updatedFields, cardKind, (preferredName) => + this.resolveFieldName(availableFieldNames, preferredName), ); - 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( diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index f426888d..59cbe267 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -4,29 +4,23 @@ import test from 'node:test'; import { CardCreationService } from './card-creation'; import { toMpvEdlValue } from './mpv-edl-test-utils'; 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[0]; -function setWordAndSentenceCardTypeFields( +function setCardTypeFields( updatedFields: Record, availableFieldNames: string[], - cardKind: 'sentence' | 'audio' | 'word-and-sentence', + cardKind: CardKind, ): void { - if (cardKind !== 'word-and-sentence') return; - - const resolveFieldName = (preferredName: string): string | null => - availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null; - const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard'); - if (!wordAndSentenceFlag) return; - - updatedFields[wordAndSentenceFlag] = 'x'; - for (const flagName of ['IsSentenceCard', 'IsAudioCard']) { - const resolved = resolveFieldName(flagName); - if (resolved && resolved !== wordAndSentenceFlag) { - updatedFields[resolved] = ''; - } - } + applyCardKindFlagFields( + updatedFields, + cardKind, + (preferredName) => + availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? + null, + ); } function createManualUpdateService(overrides: Partial = {}): { @@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc kikuFieldGrouping: 'disabled', kikuDeleteDuplicateInAuto: false, }), - setCardTypeFields: setWordAndSentenceCardTypeFields, + setCardTypeFields, }); await service.updateLastAddedFromClipboard('字幕'); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index f25d3443..abfe41f3 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -3,7 +3,7 @@ import { getConfiguredWordFieldName, getPreferredWordValueFromExtractedFields, } from '../anki-field-config'; -import { AnkiConnectConfig } from '../types/anki'; +import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki'; import { createLogger } from '../logger'; import type { MediaInput } from '../media-input'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; @@ -15,7 +15,7 @@ import { resolveAudioStreamIndexForMediaGeneration, type MediaGenerationInputResolverOptions, } from './media-source'; -import { shouldMarkWordAndSentenceCard } from './note-field-utils'; +import { resolveWordCardKind } from './note-field-utils'; import type { PendingYoutubeMediaUpdate } from './pending-youtube-media'; import { resolveMpvVolumeScale } from './mpv-volume'; @@ -42,8 +42,6 @@ export interface CardCreationNoteInfo { fields: Record; } -type CardKind = 'sentence' | 'audio' | 'word-and-sentence'; - interface CardCreationClient { addNote( deck: string, @@ -136,6 +134,7 @@ interface CardCreationDeps { kikuEnabled: boolean; kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; kikuDeleteDuplicateInAuto: boolean; + wordCardKind?: WordCardKind; }; getFallbackDurationSeconds: () => number; appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void; @@ -274,12 +273,9 @@ export class CardCreationService { if (sentenceField) { const processedSentence = this.deps.processSentence(sentence, fields); updatedFields[sentenceField] = processedSentence; - if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) { - this.deps.setCardTypeFields( - updatedFields, - Object.keys(noteInfo.fields), - 'word-and-sentence', - ); + const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig); + if (wordCardKind) { + this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind); } updatePerformed = true; } diff --git a/src/anki-integration/card-kinds.test.ts b/src/anki-integration/card-kinds.test.ts new file mode 100644 index 00000000..5832fe3b --- /dev/null +++ b/src/anki-integration/card-kinds.test.ts @@ -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 = {}; + + applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS)); + + assert.deepEqual(fields, { + IsClickCard: 'x', + IsWordAndSentenceCard: '', + IsSentenceCard: '', + IsAudioCard: '', + }); +}); + +test('matches flag fields case-insensitively', () => { + const fields: Record = {}; + + 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 = {}; + + 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 = {}; + + applyCardKindFlagFields( + fields, + 'audio', + resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']), + ); + + assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' }); +}); + +test('does not blank the target flag it just set', () => { + const fields: Record = {}; + + applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard'])); + + assert.deepEqual(fields, { IsSentenceCard: 'x' }); +}); diff --git a/src/anki-integration/card-kinds.ts b/src/anki-integration/card-kinds.ts new file mode 100644 index 00000000..a6bbf7a6 --- /dev/null +++ b/src/anki-integration/card-kinds.ts @@ -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 = { + '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(['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, + 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] = ''; + } + } +} diff --git a/src/anki-integration/note-field-utils.test.ts b/src/anki-integration/note-field-utils.test.ts new file mode 100644 index 00000000..3e4d362c --- /dev/null +++ b/src/anki-integration/note-field-utils.test.ts @@ -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 = {}): NoteFieldValueInfo { + const defaults: Record = { + 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', + ); +}); diff --git a/src/anki-integration/note-field-utils.ts b/src/anki-integration/note-field-utils.ts index 18c81ead..8d84e232 100644 --- a/src/anki-integration/note-field-utils.ts +++ b/src/anki-integration/note-field-utils.ts @@ -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 { fields: Record; } @@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0; } -export function shouldMarkWordAndSentenceCard( - noteInfo: NoteFieldValueInfo, - sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean }, -): boolean { - if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) { - return false; - } +/** Flags set only by an explicit mine action; a note carrying one is not a word card. */ +const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio]; - const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard'); - if (wordAndSentenceValue === null) { - return false; +const warnedMissingFlagFields = new Set(); + +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) { - return true; - } - return ( - !hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard') + warnedMissingFlagFields.add(wordCardKind); + log.warn( + `Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`, ); } + +/** + * 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; +} diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 107e43c7..54ca44cd 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -6,26 +6,21 @@ import { type NoteUpdateWorkflowNoteInfo, } from './note-update-workflow'; import type { SubtitleMiningContext } from '../types/subtitle'; +import type { CardKind } from '../types/anki'; +import { applyCardKindFlagFields } from './card-kinds'; -function setWordAndSentenceCardTypeFields( +function setCardTypeFields( updatedFields: Record, availableFieldNames: string[], - cardKind: 'word-and-sentence', + cardKind: CardKind, ): void { - assert.equal(cardKind, 'word-and-sentence'); - const resolveFieldName = (preferredName: string): string | null => - availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null; - - const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard'); - if (!wordAndSentenceFlag) return; - - updatedFields[wordAndSentenceFlag] = 'x'; - for (const flagName of ['IsSentenceCard', 'IsAudioCard']) { - const resolved = resolveFieldName(flagName); - if (resolved && resolved !== wordAndSentenceFlag) { - updatedFields[resolved] = ''; - } - } + applyCardKindFlagFields( + updatedFields, + cardKind, + (preferredName) => + availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? + null, + ); } function createWorkflowHarness() { @@ -79,7 +74,7 @@ function createWorkflowHarness() { handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) => false, processSentence: (text: string, _noteFields: Record) => text, - setCardTypeFields: setWordAndSentenceCardTypeFields, + setCardTypeFields, resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => { if (!preferred) return null; 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 () => { const harness = createWorkflowHarness(); harness.deps.client.notesInfo = async () => diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index e8a762e7..ff1aec6d 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -1,7 +1,8 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; 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 { noteId: number; @@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps { lapisEnabled: boolean; kikuEnabled: boolean; kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; + wordCardKind?: WordCardKind; }; appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void; extractFields: (fields: Record) => Record; @@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps { setCardTypeFields: ( updatedFields: Record, availableFieldNames: string[], - cardKind: 'word-and-sentence', + cardKind: CardKind, ) => void; resolveConfiguredFieldName: ( noteInfo: NoteUpdateWorkflowNoteInfo, @@ -207,12 +209,9 @@ export class NoteUpdateWorkflow { if (sentenceField && currentSubtitleText) { const processedSentence = this.deps.processSentence(currentSubtitleText, fields); updatedFields[sentenceField] = processedSentence; - if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) { - this.deps.setCardTypeFields( - updatedFields, - Object.keys(noteInfo.fields), - 'word-and-sentence', - ); + const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig); + if (wordCardKind) { + this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind); } updatePerformed = true; } diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts index 4db953a2..83be62e8 100644 --- a/src/anki-integration/runtime.ts +++ b/src/anki-integration/runtime.ts @@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku, ...(config.isKiku ?? {}), }, + lapisKiku: { + ...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku, + ...(config.lapisKiku ?? {}), + }, } as AnkiConnectConfig; } @@ -205,6 +209,10 @@ export class AnkiIntegrationRuntime { patch.isKiku !== undefined ? { ...this.config.isKiku, ...patch.isKiku } : this.config.isKiku, + lapisKiku: + patch.lapisKiku !== undefined + ? { ...this.config.lapisKiku, ...patch.lapisKiku } + : this.config.lapisKiku, }; this.config = normalizeAnkiIntegrationConfig(mergedConfig); this.deps.onConfigChanged?.(this.config); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 60c12f11..423cef29 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 88941c38..788564be 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -91,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< fieldGrouping: 'disabled', deleteDuplicateInAuto: true, }, + lapisKiku: { + wordCardKind: 'word-and-sentence', + }, }, jimaku: { apiBaseUrl: 'https://jimaku.cc', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index ef508e50..8ef24ecb 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -374,6 +374,21 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel, 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', kind: 'string', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 19d0d0ec..43b22c60 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ title: 'AnkiConnect Integration', description: ['Automatic Anki updates and media generation options.'], notes: [ - 'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, 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.', 'Most other AnkiConnect settings still require restart.', ], diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 0f7c151e..8ec0da31 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -1,6 +1,7 @@ import type { ResolveContext } from './context'; import { initializeAnkiConnectResolution } from './anki-connect/initialize'; import { applyAnkiKikuResolution } from './anki-connect/kiku'; +import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku'; import { applyAnkiKnownWordsResolution } from './anki-connect/known-words'; import { applyAnkiLegacyResolution } from './anki-connect/legacy'; import { applyAnkiModernResolution } from './anki-connect/modern'; @@ -22,4 +23,5 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata); applyAnkiKnownWordsResolution(context, ankiConnect, behavior); applyAnkiKikuResolution(context); + applyAnkiLapisKikuResolution(context, ankiConnect); } diff --git a/src/config/resolve/anki-connect/initialize.ts b/src/config/resolve/anki-connect/initialize.ts index fac850f0..b7571b7f 100644 --- a/src/config/resolve/anki-connect/initialize.ts +++ b/src/config/resolve/anki-connect/initialize.ts @@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution( ? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) : {}), }, + lapisKiku: { + ...context.resolved.ankiConnect.lapisKiku, + }, }; } diff --git a/src/config/resolve/anki-connect/lapis-kiku.ts b/src/config/resolve/anki-connect/lapis-kiku.ts new file mode 100644 index 00000000..140c65dd --- /dev/null +++ b/src/config/resolve/anki-connect/lapis-kiku.ts @@ -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, +): 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; +} diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 63b4df26..9cd09952 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -221,6 +221,7 @@ const LABEL_OVERRIDES: Record = { 'ankiConnect.nPlusOne.enabled': 'Enabled', 'ankiConnect.isLapis.enabled': 'Enable Lapis Features', 'ankiConnect.isKiku.enabled': 'Enable Kiku Features', + 'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type', 'stats.toggleKey': 'Toggle Stats Overlay', 'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager', 'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar', @@ -255,6 +256,8 @@ const DESCRIPTION_OVERRIDES: Record = { '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': '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': 'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.', 'subtitleStyle.secondary.css': @@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s if (path.startsWith('ankiConnect.media.')) { 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' }; } if (path.startsWith('ankiConnect.ai.')) { @@ -702,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { path === 'ankiConnect.fields.miscInfo' || path === 'ankiConnect.isLapis.sentenceCardModel' || path === 'ankiConnect.isKiku.fieldGrouping' || + path === 'ankiConnect.lapisKiku.wordCardKind' || path === 'mpv.aniskipEnabled' || path === 'mpv.aniskipButtonKey' || path === 'stats.toggleKey' || diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index e70ccdf9..1106ee53 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -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 () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index 66a9da0c..4da9ca27 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [ 'ankiConnect.fields.miscInfo', 'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isKiku.fieldGrouping', + 'ankiConnect.lapisKiku.wordCardKind', ] as const; function hotReloadFieldForChangedPath(path: string): string | null { diff --git a/src/core/services/stats-server/mining-routes.ts b/src/core/services/stats-server/mining-routes.ts index 690ac13f..d93cc90e 100644 --- a/src/core/services/stats-server/mining-routes.ts +++ b/src/core/services/stats-server/mining-routes.ts @@ -11,7 +11,7 @@ import { resolveSecondarySubtitleTextFromSidecar, } from '../secondary-subtitle-sidecar.js'; import { - applyStatsWordAndSentenceCardFields, + applyStatsWordCardFields, createStatsMiningContext, getStatsDirectMiningAudioFieldNames, getStatsWordMiningAudioFieldName, @@ -272,7 +272,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; mediaFields[sentenceFieldName] = highlightedSentence; - applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig); + applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig); if (audioBuffer) { const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`; diff --git a/src/core/services/stats-server/mining-support.ts b/src/core/services/stats-server/mining-support.ts index 9a98b318..bdb2c9c8 100644 --- a/src/core/services/stats-server/mining-support.ts +++ b/src/core/services/stats-server/mining-support.ts @@ -1,5 +1,7 @@ import type { MediaGenerator } from '../../../media-generator.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 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; } -export function applyStatsWordAndSentenceCardFields( +export function applyStatsWordCardFields( fields: Record, noteInfo: StatsServerNoteInfo | null, ankiConfig: AnkiConnectConfig, ): void { - if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return; - const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard'); - if (!wordAndSentenceFlag) return; + if (!noteInfo) return; + const cardKind = resolveWordCardKind(noteInfo, { + lapisEnabled: ankiConfig.isLapis?.enabled === true, + kikuEnabled: ankiConfig.isKiku?.enabled === true, + wordCardKind: ankiConfig.lapisKiku?.wordCardKind, + }); + if (!cardKind) return; - fields[wordAndSentenceFlag] = 'x'; - for (const flagName of ['IsSentenceCard', 'IsAudioCard']) { - const resolved = resolveStatsNoteFieldName(noteInfo, flagName); - if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = ''; - } + applyCardKindFlagFields(fields, cardKind, (preferredName) => + resolveStatsNoteFieldName(noteInfo, preferredName), + ); } export function getStatsDirectMiningAudioFieldNames( diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index 112dbadb..d8b2b761 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -73,6 +73,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log config.ankiConnect.fields.miscInfo = 'MiscInfoCustom'; config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom'; config.ankiConnect.isKiku.fieldGrouping = 'manual'; + config.ankiConnect.lapisKiku.wordCardKind = 'click'; config.logging.level = 'debug'; config.logging.rotation = 14; config.logging.files.mpv = true; @@ -114,6 +115,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log 'ankiConnect.fields.miscInfo', 'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isKiku.fieldGrouping', + 'ankiConnect.lapisKiku.wordCardKind', 'logging.level', 'logging.rotation', 'logging.files.mpv', @@ -138,6 +140,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log }, isLapis: { sentenceCardModel: 'Sentence Card Custom' }, isKiku: { fieldGrouping: 'manual' }, + lapisKiku: { wordCardKind: 'click' }, }, ]); assert.ok(calls.includes('invalidate:tokens')); diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 438826fc..1a74e9d1 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -134,6 +134,9 @@ function buildAnkiRuntimeConfigPatch( if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) { patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping }; } + if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) { + patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind }; + } return Object.keys(patch).length > 0 ? patch : null; } diff --git a/src/types/anki.ts b/src/types/anki.ts index d38c70eb..22adea92 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -2,6 +2,15 @@ import type { AiFeatureConfig } from './integrations'; import type { NotificationType } from './notification'; 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 { body?: string; icon?: string; @@ -115,4 +124,7 @@ export interface AnkiConnectConfig { fieldGrouping?: 'auto' | 'manual' | 'disabled'; deleteDuplicateInAuto?: boolean; }; + lapisKiku?: { + wordCardKind?: WordCardKind; + }; } diff --git a/src/types/config.ts b/src/types/config.ts index f6a762e3..406f63e6 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,4 +1,4 @@ -import type { AnkiConnectConfig } from './anki'; +import type { AnkiConnectConfig, WordCardKind } from './anki'; import type { AiConfig, AiFeatureConfig, @@ -285,6 +285,9 @@ export interface ResolvedConfig { fieldGrouping: 'auto' | 'manual' | 'disabled'; deleteDuplicateInAuto: boolean; }; + lapisKiku: { + wordCardKind: WordCardKind; + }; }; shortcuts: Required; secondarySub: Required;