From 83897c81d17292f9a38a0e714ac80cdb57b240dd Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 6 Jun 2026 01:50:01 -0700 Subject: [PATCH] feat(stats): add headword sentence search and rename related words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Search by headword enabled by default; finds inflected variants (e.g. 知らない → 知らねえ) - Add "Search by headword" toggle to switch back to exact text/title matching - Rename "Similar Words" → "Related Seen Words" with tighter matching (same reading/shared kanji) - ankiConnect.deck falls back to Yomitan mining deck when empty --- changes/related-seen-words.md | 4 + changes/stats-sentence-search-headword.md | 5 + config.example.jsonc | 2 +- docs-site/anki-integration.md | 7 +- docs-site/configuration.md | 104 ++--- docs-site/immersion-tracking.md | 2 +- docs-site/public/config.example.jsonc | 2 +- src/anki-connect.ts | 16 + .../definitions/options-integrations.ts | 2 +- .../services/__tests__/stats-server.test.ts | 381 +++++++++++++++++- .../services/immersion-tracker-service.ts | 9 +- .../__tests__/query-split-modules.test.ts | 75 ++++ .../immersion-tracker/__tests__/query.test.ts | 84 ++++ .../immersion-tracker/query-lexical.ts | 82 +++- src/core/services/immersion-tracker/types.ts | 9 + .../services/secondary-subtitle-sidecar.ts | 226 +++++++++++ src/core/services/stats-server.ts | 126 +++++- .../tokenizer/yomitan-parser-runtime.test.ts | 50 +++ .../tokenizer/yomitan-parser-runtime.ts | 57 ++- src/main.ts | 67 ++- .../runtime/config-settings-runtime.test.ts | 71 +++- src/main/runtime/config-settings-runtime.ts | 27 +- src/stats-daemon-runner.ts | 31 +- src/stats-word-helper-client.test.ts | 38 +- src/stats-word-helper-client.ts | 71 +++- src/stats-word-helper.ts | 72 ++-- stats/src/components/search/SearchTab.test.ts | 19 + stats/src/components/search/SearchTab.tsx | 53 ++- .../components/vocabulary/WordDetailPanel.tsx | 2 +- stats/src/lib/api-client.test.ts | 8 +- stats/src/lib/api-client.ts | 3 +- 31 files changed, 1522 insertions(+), 183 deletions(-) create mode 100644 changes/related-seen-words.md create mode 100644 changes/stats-sentence-search-headword.md create mode 100644 src/core/services/secondary-subtitle-sidecar.ts diff --git a/changes/related-seen-words.md b/changes/related-seen-words.md new file mode 100644 index 00000000..47386195 --- /dev/null +++ b/changes/related-seen-words.md @@ -0,0 +1,4 @@ +type: changed +area: stats + +- Renamed the vocabulary detail “Similar Words” section to “Related Seen Words” and tightened matches to same readings or shared kanji, avoiding noisy kana-suffix matches. diff --git a/changes/stats-sentence-search-headword.md b/changes/stats-sentence-search-headword.md new file mode 100644 index 00000000..004b5b89 --- /dev/null +++ b/changes/stats-sentence-search-headword.md @@ -0,0 +1,5 @@ +type: added +area: stats + +- Added headword-based sentence search by default, so searches like `知らない` can find tracked lines containing inflected variants such as `知らねえ` while exact sentence text searches still work. +- Added a Search by headword toggle on the Stats search page for switching back to exact text/title matching. diff --git a/config.example.jsonc b/config.example.jsonc index 9af512d0..53114269 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -496,7 +496,7 @@ "tags": [ "SubMiner" ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. - "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. + "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. "fields": { "word": "Expression", // Card field for the mined word or expression text. "audio": "ExpressionAudio", // Card field that receives generated sentence audio. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 49f16ba3..9fba9a63 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -4,11 +4,12 @@ SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add- This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior. ::: tip New to these terms? + - **Anki** is the flashcard app where your study cards live. - **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards. - A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use. - A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card. -::: + ::: ## Prerequisites @@ -22,7 +23,7 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available: -**Proxy mode** (default) - SubMiner runs a local *proxy*: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant. +**Proxy mode** (default) - SubMiner runs a local _proxy_: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant. **Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds). @@ -36,7 +37,7 @@ In both modes, the enrichment workflow is the same: 4. Fills the translation field from the secondary subtitle or AI. 5. Writes metadata to the miscInfo field. -Polling mode uses the query `"deck:" added:1` to find recently added cards. If no deck is configured, it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills from Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect. +Polling mode uses the query `"deck:" added:1` to find recently added cards. If no deck is configured, it uses Yomitan's current mining deck when available; otherwise it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect. Known-word sync scope is controlled by `ankiConnect.knownWords.decks`. ### Proxy Mode Setup (Yomitan / Texthooker) diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 4c9bb93e..6103f37e 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -52,7 +52,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf - Tracking & App - Advanced -Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and auto-fills an empty setting when one is found. Keybinding fields use click-to-learn controls instead of raw text boxes. +Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and persists it into an empty setting when one is found. Stats mining also uses Yomitan's current mining deck when `ankiConnect.deck` is empty. Keybinding fields use click-to-learn controls instead of raw text boxes. The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies. @@ -943,57 +943,57 @@ This example is intentionally compact. The option table below documents availabl **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. -| Option | Values | Description | -| ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | -| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | -| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | -| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | -| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) | -| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | -| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | -| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | -| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. In Settings, this dropdown auto-fills from Yomitan's current mining deck when available. | -| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | -| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | -| `fields.image` | string | Card field for images (default: `Picture`) | -| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | -| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | -| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | -| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | -| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | -| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | -| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | -| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | -| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | -| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | -| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | -| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | -| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | -| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | -| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | -| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | -| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | -| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | -| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. | -| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | -| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | -| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) | -| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) | -| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | -| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | -| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | -| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | -| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | -| `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.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` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | -| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | -| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | -| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | -| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | +| Option | Values | Description | +| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | +| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | +| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | +| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | +| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) | +| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | +| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | +| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | +| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. | +| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | +| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | +| `fields.image` | string | Card field for images (default: `Picture`) | +| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | +| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | +| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | +| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | +| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | +| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | +| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | +| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | +| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | +| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | +| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | +| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | +| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | +| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | +| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | +| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | +| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | +| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | +| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. | +| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | +| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | +| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) | +| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) | +| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | +| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | +| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | +| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | +| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | +| `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.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` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | +| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | +| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | +| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | +| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | `ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 38a3f9da..d5f6b994 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -100,7 +100,7 @@ Stats server config lives under `stats`: ## Mining Cards from the Stats Page -The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence: +The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence: - **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded. - **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 9af512d0..53114269 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -496,7 +496,7 @@ "tags": [ "SubMiner" ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. - "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. + "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. "fields": { "word": "Expression", // Card field for the mined word or expression text. "audio": "ExpressionAudio", // Card field that receives generated sentence audio. diff --git a/src/anki-connect.ts b/src/anki-connect.ts index 53c29252..79a80f20 100644 --- a/src/anki-connect.ts +++ b/src/anki-connect.ts @@ -156,6 +156,22 @@ export class AnkiConnectClient { return (result as number[]) || []; } + async findCards(query: string, options?: { maxRetries?: number }): Promise { + const result = await this.invoke('findCards', { query }, options); + return (result as number[]) || []; + } + + async changeDeck(cardIds: number[], deckName: string): Promise { + if (cardIds.length === 0 || !deckName.trim()) { + return; + } + + await this.invoke('changeDeck', { + cards: cardIds, + deck: deckName, + }); + } + async deckNames(): Promise { const result = await this.invoke('deckNames'); return Array.isArray(result) diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 26aacc0d..2c805931 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -63,7 +63,7 @@ export function buildIntegrationConfigOptionRegistry( kind: 'string', defaultValue: defaultConfig.ankiConnect.deck, description: - 'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.', + 'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.', }, { path: 'ankiConnect.fields.word', diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 2f4095f7..7cb16802 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -338,7 +338,10 @@ function withTempDir(fn: (dir: string) => Promise | T): Promise | T { type CapturedAnkiRequest = { action?: string; params?: { + cards?: number[]; + deck?: string; notes?: number[]; + query?: string; note?: { id?: number; deckName?: string; @@ -384,6 +387,10 @@ async function withFakeAnkiConnect( })), error: null, }; + } else if (payload.action === 'findCards') { + body = { result: [9001], error: null }; + } else if (payload.action === 'changeDeck') { + body = { result: null, error: null }; } res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -435,6 +442,43 @@ describe('stats server API routes', () => { assert.ok(Array.isArray(body)); }); + it('GET /api/stats/sentences/search resolves headword candidates by default', async () => { + const seen: Array<{ + query: string; + limit: number; + options: unknown; + }> = []; + const resolvedTerms: string[] = []; + const app = createStatsApp( + createMockTracker({ + searchSubtitleSentences: async (query: string, limit: number, options: unknown) => { + seen.push({ query, limit, options }); + return []; + }, + }), + { + resolveSentenceSearchHeadwords: async (term: string) => { + resolvedTerms.push(term); + return term === '知らない' ? ['知る'] : []; + }, + }, + ); + + const res = await app.request( + '/api/stats/sentences/search?q=%E7%9F%A5%E3%82%89%E3%81%AA%E3%81%84&limit=12', + ); + + assert.equal(res.status, 200); + assert.deepEqual(resolvedTerms, ['知らない']); + assert.deepEqual(seen, [ + { + query: '知らない', + limit: 12, + options: { headwordTerms: [{ term: '知らない', headwords: ['知る'] }] }, + }, + ]); + }); + it('GET /api/stats/sessions enriches known-word metrics using filtered persisted totals', async () => { await withTempDir(async (dir) => { const cachePath = path.join(dir, 'known-words.json'); @@ -1162,12 +1206,62 @@ describe('stats server API routes', () => { const addNoteRequest = requests.find((request) => request.action === 'addNote'); assert.equal(addNoteRequest?.params?.note?.deckName, 'Default'); assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph'); - assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, 'を見た'); + assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, '猫を見た'); assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x'); }); }); }); + it('POST /api/stats/mine-card uses Yomitan deck for direct sentence cards when config deck is empty', 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(), { + getYomitanAnkiDeckName: async () => 'Minecraft', + ankiConnectConfig: { + url, + deck: '', + tags: ['SubMiner'], + fields: { + word: 'Expression', + sentence: 'Sentence', + translation: 'SelectionText', + }, + media: { + generateAudio: false, + generateImage: false, + }, + isLapis: { + enabled: true, + sentenceCardModel: 'Lapis Morph', + }, + }, + }); + + const res = await app.request('/api/stats/mine-card?mode=sentence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 1_000, + endMs: 2_000, + sentence: '猫を見た', + word: '猫', + secondaryText: 'I saw a cat', + videoTitle: 'Episode 1', + }), + }); + + const body = await res.json(); + assert.equal(res.status, 200, JSON.stringify(body)); + const addNoteRequest = requests.find((request) => request.action === 'addNote'); + assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft'); + }); + }); + }); + it('POST /api/stats/mine-card resolves Anki config at request time', async () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); @@ -1294,7 +1388,7 @@ describe('stats server API routes', () => { }); }); - it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary fields', async () => { + it('POST /api/stats/mine-card writes secondary subtitles to word card selection text', async () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); fs.writeFileSync(sourcePath, 'fake media'); @@ -1340,7 +1434,286 @@ describe('stats server API routes', () => { const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); assert.equal(updateRequest?.params?.note?.id, 777); assert.equal(updateRequest?.params?.note?.fields?.Sentence, 'を見た'); - assert.equal(updateRequest?.params?.note?.fields?.SelectionText, undefined); + assert.equal(updateRequest?.params?.note?.fields?.SelectionText, 'I saw a cat'); + }); + }); + }); + + it('POST /api/stats/mine-card moves Yomitan-created word notes to the configured deck', 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, + ankiConnectConfig: { + url, + deck: 'Mining', + fields: { + sentence: 'Sentence', + translation: 'SelectionText', + }, + media: { + generateAudio: false, + generateImage: false, + }, + }, + }); + + 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 findCardsRequest = requests.find((request) => request.action === 'findCards'); + assert.equal(findCardsRequest?.params?.query, 'nid:777'); + const changeDeckRequest = requests.find((request) => request.action === 'changeDeck'); + assert.deepEqual(changeDeckRequest?.params?.cards, [9001]); + assert.equal(changeDeckRequest?.params?.deck, 'Mining'); + }); + }); + }); + + it('POST /api/stats/mine-card moves Yomitan-created word notes to Yomitan deck when config deck is empty', 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(), { + getYomitanAnkiDeckName: async () => 'Minecraft', + addYomitanNote: async () => 777, + ankiConnectConfig: { + url, + deck: '', + fields: { + sentence: 'Sentence', + translation: 'SelectionText', + }, + media: { + generateAudio: false, + generateImage: false, + }, + }, + }); + + 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 findCardsRequest = requests.find((request) => request.action === 'findCards'); + assert.equal(findCardsRequest?.params?.query, 'nid:777'); + const changeDeckRequest = requests.find((request) => request.action === 'changeDeck'); + assert.deepEqual(changeDeckRequest?.params?.cards, [9001]); + assert.equal(changeDeckRequest?.params?.deck, 'Minecraft'); + }); + }); + }); + + it('POST /api/stats/mine-card uses the full sentence as sentence-card expression', 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(), { + ankiConnectConfig: { + url, + deck: 'Minecraft', + tags: ['SubMiner'], + fields: { + word: 'Expression', + sentence: 'Sentence', + translation: 'SelectionText', + }, + media: { + generateAudio: false, + generateImage: false, + }, + isLapis: { + enabled: true, + sentenceCardModel: 'Lapis Morph', + }, + }, + }); + + const res = await app.request('/api/stats/mine-card?mode=sentence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 1_000, + endMs: 2_000, + sentence: '猫を見た', + word: '猫', + secondaryText: 'I saw a cat', + videoTitle: 'Episode 1', + }), + }); + + const body = await res.json(); + assert.equal(res.status, 200, JSON.stringify(body)); + + const addNoteRequest = requests.find((request) => request.action === 'addNote'); + assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft'); + assert.equal(addNoteRequest?.params?.note?.fields?.Expression, '猫を見た'); + assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, '猫を見た'); + assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat'); + assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x'); + }); + }); + }); + + it('POST /api/stats/mine-card fills selection text from a matching secondary sidecar subtitle', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + fs.writeFileSync( + path.join(dir, 'episode.en.srt'), + [ + '1', + '00:00:00,800 --> 00:00:02,500', + 'I saw a cat.', + '', + '2', + '00:00:03,000 --> 00:00:04,000', + 'Not this line.', + '', + ].join('\n'), + ); + + await withFakeAnkiConnect(async (requests, url) => { + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: { + url, + deck: 'Minecraft', + tags: ['SubMiner'], + fields: { + word: 'Expression', + sentence: 'Sentence', + translation: 'SelectionText', + }, + media: { + generateAudio: false, + generateImage: false, + }, + isLapis: { + enabled: true, + sentenceCardModel: 'Lapis Morph', + }, + }, + secondarySubtitleLanguages: ['en'], + } as Parameters[1]); + + const res = await app.request('/api/stats/mine-card?mode=sentence', { + 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 addNoteRequest = requests.find((request) => request.action === 'addNote'); + assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft'); + assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat.'); + }); + }); + }); + + it('POST /api/stats/mine-card does not append the next sidecar cue near a timing boundary', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + fs.writeFileSync( + path.join(dir, 'episode.en.srt'), + [ + '1', + '00:00:00,800 --> 00:00:01,500', + "I don't give a damn what family she's from.", + '', + '2', + '00:00:01,700 --> 00:00:03,000', + 'That snobby attitude just pisses me off!', + '', + ].join('\n'), + ); + + await withFakeAnkiConnect(async (requests, url) => { + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: { + url, + deck: 'Minecraft', + tags: ['SubMiner'], + fields: { + word: 'Expression', + sentence: 'Sentence', + translation: 'SelectionText', + }, + media: { + generateAudio: false, + generateImage: false, + }, + isLapis: { + enabled: true, + sentenceCardModel: 'Lapis Morph', + }, + }, + secondarySubtitleLanguages: ['en'], + } as Parameters[1]); + + const res = await app.request('/api/stats/mine-card?mode=sentence', { + 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 addNoteRequest = requests.find((request) => request.action === 'addNote'); + assert.equal( + addNoteRequest?.params?.note?.fields?.SelectionText, + "I don't give a damn what family she's from.", + ); }); }); }); @@ -1466,6 +1839,8 @@ describe('stats server API routes', () => { 'generateAudio', 'generateScreenshot', 'addNote', + 'findCards', + 'changeDeck', 'uploadAudio', 'uploadImage', 'updateNoteFields', diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 82e192ae..5d69b570 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -149,6 +149,7 @@ import { type MediaLibraryRow, type NewAnimePerDayRow, type QueuedWrite, + type SentenceSearchOptions, type SentenceSearchResultRow, type SessionEventRow, type SessionState, @@ -570,8 +571,12 @@ export class ImmersionTrackerService { return getKanjiOccurrences(this.db, kanji, limit, offset); } - async searchSubtitleSentences(query: string, limit = 50): Promise { - return searchSubtitleSentences(this.db, query, limit); + async searchSubtitleSentences( + query: string, + limit = 50, + options?: SentenceSearchOptions, + ): Promise { + return searchSubtitleSentences(this.db, query, limit, options); } async getSessionEvents( diff --git a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts index a7543a72..2dad5990 100644 --- a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts @@ -356,6 +356,81 @@ test('split session and lexical helpers return distinct-headword, detail, appear } }); +test('similar words use same reading and shared kanji without kana suffix noise', () => { + const { db, dbPath, stmts } = createDb(); + + try { + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Similar Words Anime', + canonicalTitle: 'Similar Words Anime', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/similar-words.mkv', { + canonicalTitle: 'Similar Words Episode', + sourcePath: '/tmp/similar-words.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const sessionId = startSessionRecord(db, videoId, 1_000_000).sessionId; + + const araiId = insertWordOccurrence(db, stmts, { + sessionId, + videoId, + animeId, + lineIndex: 1, + text: '荒い息', + word: { headword: '荒い', word: '荒い', reading: 'あらい' }, + }); + insertWordOccurrence(db, stmts, { + sessionId, + videoId, + animeId, + lineIndex: 2, + text: '洗い物', + word: { headword: '洗い', word: '洗い', reading: 'あらい' }, + }); + insertWordOccurrence(db, stmts, { + sessionId, + videoId, + animeId, + lineIndex: 3, + text: '荒波', + word: { headword: '荒波', word: '荒波', reading: 'あらなみ' }, + }); + + for (let lineIndex = 4; lineIndex < 9; lineIndex++) { + insertWordOccurrence(db, stmts, { + sessionId, + videoId, + animeId, + lineIndex, + text: '良い', + word: { headword: '良い', word: '良い', reading: 'よい' }, + }); + } + insertWordOccurrence(db, stmts, { + sessionId, + videoId, + animeId, + lineIndex: 9, + text: 'お構いなく', + word: { headword: 'お構いなく', word: 'お構いなく', reading: 'おかまいなく' }, + }); + + assert.deepEqual( + getSimilarWords(db, araiId, 10).map((row) => row.headword), + ['洗い', '荒波'], + ); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('split library helpers return anime/media session and analytics rows', () => { const { db, dbPath, stmts } = createDb(); diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index c8f91932..6b21f9bf 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -3785,6 +3785,90 @@ test('searchSubtitleSentences searches known subtitle lines and returns media co } }); +test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Little Witch Academia', + canonicalTitle: 'Little Witch Academia', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: '{"source":"test"}', + }); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lwa-05.mkv', { + canonicalTitle: 'Episode 5', + sourcePath: '/tmp/Little Witch Academia S01E05.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'Little Witch Academia S01E05.mkv', + parsedTitle: 'Little Witch Academia', + parsedSeason: 1, + parsedEpisode: 5, + parserSource: 'fallback', + parserConfidence: 1, + parseMetadataJson: '{"episode":5}', + }); + const { sessionId } = startSessionRecord(db, videoId, 4_000_000); + const lineResult = db + .prepare( + `INSERT INTO imm_subtitle_lines ( + session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, + text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + sessionId, + null, + videoId, + animeId, + 20, + 247_000, + 250_000, + 'ああ、名無しが何だか知らねえが', + null, + 4_000, + 4_000, + ); + const wordResult = db + .prepare( + `INSERT INTO imm_words ( + headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run('知る', '知らねえ', 'しらねえ', 'verb', '動詞', '自立', '', 4_000, 4_000, 1); + db.prepare( + `INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) + VALUES (?, ?, ?)`, + ).run(Number(lineResult.lastInsertRowid), Number(wordResult.lastInsertRowid), 1); + + assert.deepEqual(searchSubtitleSentences(db, '知らない', 10), []); + + const rows = searchSubtitleSentences(db, '知らない', 10, { + headwordTerms: [{ term: '知らない', headwords: ['知る'] }], + }); + + assert.deepEqual( + rows.map((row) => row.text), + ['ああ、名無しが何だか知らねえが'], + ); + assert.deepEqual( + searchSubtitleSentences(db, '知らねえ', 10).map((row) => row.text), + ['ああ、名無しが何だか知らねえが'], + ); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); diff --git a/src/core/services/immersion-tracker/query-lexical.ts b/src/core/services/immersion-tracker/query-lexical.ts index d5bddd1a..178deddf 100644 --- a/src/core/services/immersion-tracker/query-lexical.ts +++ b/src/core/services/immersion-tracker/query-lexical.ts @@ -7,6 +7,7 @@ import type { KanjiOccurrenceRow, KanjiStatsRow, KanjiWordRow, + SentenceSearchOptions, SentenceSearchResultRow, SessionEventRow, SimilarWordRow, @@ -23,6 +24,7 @@ const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4; const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100; const SENTENCE_SEARCH_DEFAULT_LIMIT = 50; const SENTENCE_SEARCH_MAX_LIMIT = 100; +const KANJI_PATTERN = /\p{Script=Han}/gu; function resolveSentenceSearchLimit(limit: number): number { if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT; @@ -31,7 +33,7 @@ function resolveSentenceSearchLimit(limit: number): number { return Math.min(normalized, SENTENCE_SEARCH_MAX_LIMIT); } -function splitSearchTerms(query: string): string[] { +export function splitSentenceSearchTerms(query: string): string[] { return query .trim() .split(/\s+/) @@ -44,6 +46,33 @@ function escapeLikeTerm(term: string): string { return term.replace(/[\\%_]/g, (match) => `\\${match}`); } +function uniqueNonEmptyTerms(values: readonly string[] | undefined): string[] { + const seen = new Set(); + const terms: string[] = []; + for (const value of values ?? []) { + const term = value.trim(); + if (!term || seen.has(term)) continue; + seen.add(term); + terms.push(term); + } + return terms; +} + +function getHeadwordCandidatesForSentenceSearchTerm( + term: string, + options: SentenceSearchOptions | undefined, +): string[] { + const headwords = + options?.headwordTerms + ?.filter((entry) => entry.term === term) + .flatMap((entry) => entry.headwords) ?? []; + return uniqueNonEmptyTerms(headwords); +} + +function uniqueKanji(text: string): string[] { + return Array.from(new Set(text.match(KANJI_PATTERN) ?? [])); +} + function toVocabularyToken(row: VocabularyStatsRow): MergedToken { const partOfSpeech = row.partOfSpeech && Object.values(PartOfSpeech).includes(row.partOfSpeech as PartOfSpeech) @@ -238,8 +267,9 @@ export function searchSubtitleSentences( db: DatabaseSync, query: string, limit = SENTENCE_SEARCH_DEFAULT_LIMIT, + options?: SentenceSearchOptions, ): SentenceSearchResultRow[] { - const terms = splitSearchTerms(query); + const terms = splitSentenceSearchTerms(query); if (terms.length === 0) return []; const resolvedLimit = resolveSentenceSearchLimit(limit); @@ -247,14 +277,28 @@ export function searchSubtitleSentences( const params: string[] = []; for (const term of terms) { const likeTerm = `%${escapeLikeTerm(term)}%`; + const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options); + const headwordClause = + headwords.length > 0 + ? ` + OR EXISTS ( + SELECT 1 + FROM imm_word_line_occurrences o + JOIN imm_words w ON w.id = o.word_id + WHERE o.line_id = l.line_id + AND w.headword IN (${headwords.map(() => '?').join(', ')}) + ) + ` + : ''; clauses.push(` ( l.text LIKE ? ESCAPE '\\' OR v.canonical_title LIKE ? ESCAPE '\\' OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\' + ${headwordClause} ) `); - params.push(likeTerm, likeTerm, likeTerm); + params.push(likeTerm, likeTerm, likeTerm, ...headwords); } return db @@ -359,24 +403,38 @@ export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): S reading: string; } | null; if (!word || word.headword.trim() === '') return []; + + const clauses: string[] = []; + const params: string[] = []; + const reading = word.reading.trim(); + if (reading !== '') { + clauses.push('reading = ?'); + params.push(word.reading); + } + + for (const kanji of uniqueKanji(word.headword)) { + clauses.push("headword LIKE ? ESCAPE '\\'"); + params.push(`%${escapeLikeTerm(kanji)}%`); + } + + if (clauses.length === 0) return []; + + const orderBy = + reading !== '' ? 'CASE WHEN reading = ? THEN 0 ELSE 1 END, frequency DESC' : 'frequency DESC'; + const orderParams = reading !== '' ? [word.reading] : []; + return db .prepare( ` SELECT id AS wordId, headword, word, reading, frequency FROM imm_words WHERE id != ? - AND (reading = ? OR headword LIKE ? OR headword LIKE ?) - ORDER BY frequency DESC + AND (${clauses.join(' OR ')}) + ORDER BY ${orderBy} LIMIT ? `, ) - .all( - wordId, - word.reading, - `%${word.headword.charAt(0)}%`, - `%${word.headword.charAt(word.headword.length - 1)}%`, - limit, - ) as SimilarWordRow[]; + .all(wordId, ...params, ...orderParams, limit) as SimilarWordRow[]; } export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null { diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts index ee2844b6..58965218 100644 --- a/src/core/services/immersion-tracker/types.ts +++ b/src/core/services/immersion-tracker/types.ts @@ -381,6 +381,15 @@ export interface SentenceSearchResultRow { text: string; } +export interface SentenceSearchHeadwordTerm { + term: string; + headwords: string[]; +} + +export interface SentenceSearchOptions { + headwordTerms?: SentenceSearchHeadwordTerm[]; +} + export interface SessionEventRow { eventType: number; tsMs: number; diff --git a/src/core/services/secondary-subtitle-sidecar.ts b/src/core/services/secondary-subtitle-sidecar.ts new file mode 100644 index 00000000..c4b68587 --- /dev/null +++ b/src/core/services/secondary-subtitle-sidecar.ts @@ -0,0 +1,226 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js'; +import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.js'; + +const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'en-us', 'enus']; +const SUPPORTED_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass', '.ssa']); +const TIMING_TOLERANCE_SECONDS = 0.25; +const SAME_TIMING_EPSILON_SECONDS = 0.001; + +type SidecarCandidate = { + path: string; + languageRank: number; + extensionRank: number; + name: string; +}; + +function unique(values: string[]): string[] { + return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index); +} + +function expandPreferredLanguages(languages: readonly string[] | undefined): string[] { + const normalized = unique( + (languages ?? []).map((language) => normalizeYoutubeLangCode(language)).filter(Boolean), + ); + const base = normalized.length > 0 ? normalized : DEFAULT_SECONDARY_SUBTITLE_LANGUAGES; + const expanded: string[] = []; + for (const language of base) { + expanded.push(language); + if (isEnglishYoutubeLang(language)) { + expanded.push(...DEFAULT_SECONDARY_SUBTITLE_LANGUAGES); + } + } + return unique(expanded); +} + +function splitLanguageSuffix(value: string): string[] { + const normalizedWhole = normalizeYoutubeLangCode(value); + const tokens = value + .split(/[^A-Za-z0-9-]+/g) + .map((token) => normalizeYoutubeLangCode(token)) + .filter(Boolean); + return unique([normalizedWhole, ...tokens]); +} + +function languageTokenMatches(token: string, preferredLanguage: string): boolean { + if (token === preferredLanguage) { + return true; + } + if (token.startsWith(`${preferredLanguage}-`) || preferredLanguage.startsWith(`${token}-`)) { + return true; + } + return isEnglishYoutubeLang(token) && isEnglishYoutubeLang(preferredLanguage); +} + +function resolveLanguageRank(suffix: string, preferredLanguages: string[]): number { + const tokens = splitLanguageSuffix(suffix); + for (let index = 0; index < preferredLanguages.length; index += 1) { + const preferredLanguage = preferredLanguages[index]!; + if (tokens.some((token) => languageTokenMatches(token, preferredLanguage))) { + return index; + } + } + return Number.POSITIVE_INFINITY; +} + +function extensionRank(ext: string): number { + if (ext === '.srt') return 0; + if (ext === '.vtt') return 1; + if (ext === '.ass') return 2; + if (ext === '.ssa') return 3; + return 4; +} + +function findSidecarSubtitleCandidates( + sourcePath: string, + preferredLanguages: string[], +): SidecarCandidate[] { + const source = path.parse(sourcePath); + let entries: string[]; + try { + entries = readdirSync(source.dir); + } catch { + return []; + } + + const prefix = `${source.name}.`; + return entries + .map((entry) => { + const parsed = path.parse(entry); + const ext = parsed.ext.toLowerCase(); + if (!SUPPORTED_SUBTITLE_EXTENSIONS.has(ext) || !parsed.name.startsWith(prefix)) { + return null; + } + const suffix = parsed.name.slice(prefix.length); + const languageRank = resolveLanguageRank(suffix, preferredLanguages); + if (!Number.isFinite(languageRank)) { + return null; + } + return { + path: path.join(source.dir, entry), + languageRank, + extensionRank: extensionRank(ext), + name: entry, + }; + }) + .filter((candidate): candidate is SidecarCandidate => candidate !== null) + .sort((left, right) => { + if (left.languageRank !== right.languageRank) return left.languageRank - right.languageRank; + if (left.extensionRank !== right.extensionRank) + return left.extensionRank - right.extensionRank; + return left.name.localeCompare(right.name); + }); +} + +function combineCueText(cues: SubtitleCue[]): string { + return unique(cues.map((cue) => cue.text.trim()).filter(Boolean)) + .join('\n') + .trim(); +} + +function overlapSeconds(cue: SubtitleCue, startSeconds: number, endSeconds: number): number { + return ( + Math.min(cue.endTime, endSeconds + TIMING_TOLERANCE_SECONDS) - + Math.max(cue.startTime, startSeconds - TIMING_TOLERANCE_SECONDS) + ); +} + +function isSameCueTiming(left: SubtitleCue, right: SubtitleCue): boolean { + return ( + Math.abs(left.startTime - right.startTime) <= SAME_TIMING_EPSILON_SECONDS && + Math.abs(left.endTime - right.endTime) <= SAME_TIMING_EPSILON_SECONDS + ); +} + +function compareCueTimingMatch( + startSeconds: number, + endSeconds: number, + left: { cue: SubtitleCue; overlap: number }, + right: { cue: SubtitleCue; overlap: number }, +): number { + if (left.overlap !== right.overlap) { + return right.overlap - left.overlap; + } + + const leftStartDistance = Math.abs(left.cue.startTime - startSeconds); + const rightStartDistance = Math.abs(right.cue.startTime - startSeconds); + if (leftStartDistance !== rightStartDistance) { + return leftStartDistance - rightStartDistance; + } + + const leftEndDistance = Math.abs(left.cue.endTime - endSeconds); + const rightEndDistance = Math.abs(right.cue.endTime - endSeconds); + if (leftEndDistance !== rightEndDistance) { + return leftEndDistance - rightEndDistance; + } + + return left.cue.startTime - right.cue.startTime; +} + +function findCueTextAtTiming(cues: SubtitleCue[], startMs: number, endMs: number): string { + const startSeconds = startMs / 1000; + const endSeconds = endMs / 1000; + const midpointSeconds = (startSeconds + endSeconds) / 2; + + const midpointMatches = cues + .filter( + (cue) => + cue.startTime - TIMING_TOLERANCE_SECONDS <= midpointSeconds && + cue.endTime + TIMING_TOLERANCE_SECONDS >= midpointSeconds, + ) + .map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) })) + .sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right)); + const [bestMidpointMatch] = midpointMatches; + const midpointText = bestMidpointMatch + ? combineCueText( + midpointMatches + .filter((match) => isSameCueTiming(match.cue, bestMidpointMatch.cue)) + .map((match) => match.cue), + ) + : ''; + if (midpointText) { + return midpointText; + } + + const [bestOverlap] = cues + .map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) })) + .filter((entry) => entry.overlap > 0) + .sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right)); + return bestOverlap ? bestOverlap.cue.text.trim() : ''; +} + +export function resolveSecondarySubtitleTextFromSidecar(input: { + sourcePath: string; + startMs: number; + endMs: number; + languages?: readonly string[]; +}): string { + if (!input.sourcePath || !existsSync(input.sourcePath)) { + return ''; + } + try { + if (!statSync(input.sourcePath).isFile()) { + return ''; + } + } catch { + return ''; + } + + const preferredLanguages = expandPreferredLanguages(input.languages); + const candidates = findSidecarSubtitleCandidates(input.sourcePath, preferredLanguages); + for (const candidate of candidates) { + try { + const content = readFileSync(candidate.path, 'utf8'); + const cues = parseSubtitleCues(content, candidate.path); + const text = findCueTextAtTiming(cues, input.startMs, input.endMs); + if (text) { + return text; + } + } catch { + // Try the next matching sidecar. + } + } + + return ''; +} diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 314ea3ab..640d3256 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import type { ImmersionTrackerService } from './immersion-tracker-service.js'; +import { splitSentenceSearchTerms } from './immersion-tracker/query-lexical.js'; import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import { basename, extname, resolve, sep } from 'node:path'; import { readFileSync, existsSync, statSync } from 'node:fs'; @@ -16,6 +17,7 @@ import { } from '../../anki-field-config.js'; import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js'; import type { AnilistRateLimiter } from './anilist/rate-limiter.js'; +import { resolveSecondarySubtitleTextFromSidecar } from './secondary-subtitle-sidecar.js'; type StatsServerNoteInfo = { noteId: number; @@ -356,9 +358,13 @@ export interface StatsServerConfig { mpvSocketPath?: string; ankiConnectConfig?: AnkiConnectConfig; getAnkiConnectConfig?: () => AnkiConnectConfig | undefined; + getYomitanAnkiDeckName?: () => Promise | string | null | undefined; + secondarySubtitleLanguages?: string[]; + getSecondarySubtitleLanguages?: () => string[] | undefined; anilistRateLimiter?: AnilistRateLimiter; addYomitanNote?: (word: string) => Promise; resolveAnkiNoteId?: (noteId: number) => number; + resolveSentenceSearchHeadwords?: (term: string) => Promise | string[]; } const STATS_STATIC_CONTENT_TYPES: Record = { @@ -385,6 +391,47 @@ function defaultNowMs(): number { return Date.now(); } +function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean { + if (raw === undefined) return fallback; + const normalized = raw.trim().toLowerCase(); + if (!normalized) return fallback; + return !['0', 'false', 'no', 'off'].includes(normalized); +} + +function uniqueNonEmptyStrings(values: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +async function buildSentenceSearchOptions( + query: string, + searchByHeadword: boolean, + resolveSentenceSearchHeadwords: ((term: string) => Promise | string[]) | undefined, +): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> { + if (!searchByHeadword) return undefined; + + const terms = splitSentenceSearchTerms(query); + const headwordTerms: Array<{ term: string; headwords: string[] }> = []; + for (const term of terms) { + const resolved = resolveSentenceSearchHeadwords + ? await resolveSentenceSearchHeadwords(term) + : [term]; + const headwords = uniqueNonEmptyStrings(resolved); + if (headwords.length > 0) { + headwordTerms.push({ term, headwords }); + } + } + + return headwordTerms.length > 0 ? { headwordTerms } : undefined; +} + function buildAnkiNotePreview( fields: Record, ankiConfig?: Pick, @@ -446,9 +493,13 @@ export function createStatsApp( mpvSocketPath?: string; ankiConnectConfig?: AnkiConnectConfig; getAnkiConnectConfig?: () => AnkiConnectConfig | undefined; + getYomitanAnkiDeckName?: () => Promise | string | null | undefined; + secondarySubtitleLanguages?: string[]; + getSecondarySubtitleLanguages?: () => string[] | undefined; anilistRateLimiter?: AnilistRateLimiter; addYomitanNote?: (word: string) => Promise; resolveAnkiNoteId?: (noteId: number) => number; + resolveSentenceSearchHeadwords?: (term: string) => Promise | string[]; createMediaGenerator?: () => StatsServerMediaGenerator; onMiningTiming?: (event: StatsMiningTimingEvent) => void; nowMs?: () => number; @@ -458,6 +509,23 @@ export function createStatsApp( const nowMs = options?.nowMs ?? defaultNowMs; const getAnkiConnectConfig = (): AnkiConnectConfig | undefined => options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig; + const getSecondarySubtitleLanguages = (): string[] => + options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? []; + const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise => { + const configuredDeckName = ankiConfig.deck?.trim() ?? ''; + if (configuredDeckName) return configuredDeckName; + + try { + const yomitanDeckName = await options?.getYomitanAnkiDeckName?.(); + return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : ''; + } catch (error) { + statsMiningLogger.warn( + 'Failed to resolve Yomitan Anki deck for stats mining:', + error instanceof Error ? error.message : String(error), + ); + return ''; + } + }; const recordMiningTiming = (event: StatsMiningTimingEvent): void => { options?.onMiningTiming?.(event); @@ -659,7 +727,13 @@ export function createStatsApp( const query = (c.req.query('q') ?? '').trim(); if (!query) return c.json([]); const limit = parseIntQuery(c.req.query('limit'), 50, 100); - const rows = await tracker.searchSubtitleSentences(query, limit); + const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true); + const searchOptions = await buildSentenceSearchOptions( + query, + searchByHeadword, + options?.resolveSentenceSearchHeadwords, + ); + const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions); return c.json(rows); }); @@ -997,7 +1071,8 @@ export function createStatsApp( const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN; const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : ''; const word = typeof body?.word === 'string' ? body.word.trim() : ''; - const secondaryText = typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : ''; + const bodySecondaryText = + typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : ''; const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : ''; const rawMode = c.req.query('mode'); const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence'; @@ -1014,6 +1089,14 @@ export function createStatsApp( if (!ankiConfig) { return c.json({ error: 'AnkiConnect is not configured' }, 500); } + const secondaryText = + bodySecondaryText || + resolveSecondarySubtitleTextFromSidecar({ + sourcePath, + startMs, + endMs, + languages: getSecondarySubtitleLanguages(), + }); const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765'); const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator(); @@ -1080,6 +1163,25 @@ export function createStatsApp( const errors: string[] = []; let noteId: number; + let effectiveDeckNamePromise: Promise | null = null; + const getEffectiveDeckNameForRequest = (): Promise => { + effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig); + return effectiveDeckNamePromise; + }; + const moveNoteToConfiguredDeck = async (id: number): Promise => { + const deckName = await getEffectiveDeckNameForRequest(); + if (!deckName) { + return; + } + try { + const cardIds = await timeMiningPhase(mode, 'findCards', () => + client.findCards(`nid:${id}`), + ); + await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName)); + } catch (err) { + errors.push(`deck: ${(err as Error).message}`); + } + }; if (mode === 'word') { if (!options?.addYomitanNote) { @@ -1107,6 +1209,7 @@ export function createStatsApp( } noteId = yomitanResult.value; + await moveNoteToConfiguredDeck(noteId); const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; if (audioResult.status === 'rejected') errors.push(`audio: ${(audioResult.reason as Error).message}`); @@ -1145,10 +1248,14 @@ export function createStatsApp( const mediaFields: Record = {}; const timestamp = Date.now(); const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; + const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo); const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; mediaFields[sentenceFieldName] = highlightedSentence; + if (secondaryText) { + mediaFields[translationFieldName] = secondaryText; + } if (audioBuffer) { const audioFilename = `subminer_audio_${timestamp}.mp3`; @@ -1214,7 +1321,7 @@ export function createStatsApp( const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? ''; const fields: Record = { - [sentenceFieldName]: highlightedSentence, + [sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence, }; if (mode === 'sentence' && secondaryText) { @@ -1222,7 +1329,9 @@ export function createStatsApp( } if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) { - if (word) { + if (mode === 'sentence') { + fields[wordFieldName] = sentence; + } else if (word) { fields[wordFieldName] = word; } if (mode === 'sentence') { @@ -1233,13 +1342,13 @@ export function createStatsApp( } const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic'; - const deck = ankiConfig.deck?.trim() || 'Default'; const tags = ankiConfig.tags ?? ['SubMiner']; const addNotePromise = timeMiningPhase( mode, 'addNote', - () => client.addNote(deck, model, fields, tags), + async () => + client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags), (id) => ({ noteId: id, }), @@ -1265,6 +1374,7 @@ export function createStatsApp( ); } noteId = addNoteResult.value; + await moveNoteToConfiguredDeck(noteId); const mediaFields: Record = {}; const timestamp = Date.now(); @@ -1370,9 +1480,13 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void mpvSocketPath: config.mpvSocketPath, ankiConnectConfig: config.ankiConnectConfig, getAnkiConnectConfig: config.getAnkiConnectConfig, + getYomitanAnkiDeckName: config.getYomitanAnkiDeckName, + secondarySubtitleLanguages: config.secondarySubtitleLanguages, + getSecondarySubtitleLanguages: config.getSecondarySubtitleLanguages, anilistRateLimiter: config.anilistRateLimiter, addYomitanNote: config.addYomitanNote, resolveAnkiNoteId: config.resolveAnkiNoteId, + resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords, }); const bunRuntime = globalThis as typeof globalThis & { diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts index 47fde958..1db5c8cd 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts @@ -151,6 +151,56 @@ test('syncYomitanDefaultAnkiServer injects force override when enabled', async ( assert.match(scriptValue, /forceOverride = true/); }); +test('syncYomitanDefaultAnkiServer updates the active profile Anki deck', async () => { + const optionsFull = { + profileCurrent: 0, + profiles: [ + { + options: { + anki: { + server: 'http://127.0.0.1:8766', + cardFormats: [ + { type: 'term', deck: 'Default', model: 'Mining Note', fields: {} }, + { type: 'kanji', deck: 'Kanji', model: 'Kanji Note', fields: {} }, + ], + terms: { deck: 'Default', model: 'Legacy Note', fields: {} }, + }, + }, + }, + ], + }; + let savedOptions: typeof optionsFull | null = null; + const deps = createDeps((script) => + runInjectedYomitanScript(script, (action, params) => { + if (action === 'optionsGetFull') { + return JSON.parse(JSON.stringify(optionsFull)); + } + if (action === 'setAllSettings') { + savedOptions = (params as { value: typeof optionsFull }).value; + return true; + } + throw new Error(`Unexpected action: ${action}`); + }), + ); + + const synced = await syncYomitanDefaultAnkiServer( + 'http://127.0.0.1:8766', + deps, + { + error: () => undefined, + info: () => undefined, + }, + { deck: 'Minecraft', forceOverride: true }, + ); + + assert.equal(synced, true); + assert.ok(savedOptions); + const saved = savedOptions as typeof optionsFull; + assert.equal(saved.profiles[0]?.options.anki.cardFormats[0]?.deck, 'Minecraft'); + assert.equal(saved.profiles[0]?.options.anki.cardFormats[1]?.deck, 'Kanji'); + assert.equal(saved.profiles[0]?.options.anki.terms.deck, 'Minecraft'); +}); + test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => { const deps = createDeps(async () => { throw new Error('execute failed'); diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index 3b1767ab..1835b755 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -1783,6 +1783,7 @@ export async function syncYomitanDefaultAnkiServer( logger: LoggerLike, options?: { forceOverride?: boolean; + deck?: string; }, ): Promise { const normalizedTargetServer = serverUrl.trim(); @@ -1790,6 +1791,7 @@ export async function syncYomitanDefaultAnkiServer( return false; } const forceOverride = options?.forceOverride === true; + const normalizedTargetDeck = options?.deck?.trim() ?? ''; const isReady = await ensureYomitanParserWindow(deps, logger); const parserWindow = deps.getYomitanParserWindow(); @@ -1819,6 +1821,7 @@ export async function syncYomitanDefaultAnkiServer( }); const targetServer = ${JSON.stringify(normalizedTargetServer)}; + const targetDeck = ${JSON.stringify(normalizedTargetDeck)}; const forceOverride = ${forceOverride ? 'true' : 'false'}; const optionsFull = await invoke("optionsGetFull", undefined); const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : []; @@ -1843,18 +1846,54 @@ export async function syncYomitanDefaultAnkiServer( const currentServerRaw = targetProfile.options.anki.server; const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : ""; - if (currentServer === targetServer) { - return { updated: false, matched: true, reason: "already-target", currentServer, targetServer }; - } - const canReplaceCurrent = - forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765"; - if (!canReplaceCurrent) { - return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer }; + let changed = false; + if (currentServer !== targetServer) { + const canReplaceCurrent = + forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765"; + if (!canReplaceCurrent) { + return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer }; + } + + targetProfile.options.anki.server = targetServer; + changed = true; + } + + if (targetDeck) { + const cardFormats = Array.isArray(targetProfile.options.anki.cardFormats) + ? targetProfile.options.anki.cardFormats + : []; + for (const cardFormat of cardFormats) { + if ( + !cardFormat || + typeof cardFormat !== "object" || + cardFormat.type !== "term" || + cardFormat.enabled === false + ) { + continue; + } + const currentDeck = typeof cardFormat.deck === "string" ? cardFormat.deck.trim() : ""; + if (currentDeck !== targetDeck) { + cardFormat.deck = targetDeck; + changed = true; + } + } + + const terms = targetProfile.options.anki.terms; + if (terms && typeof terms === "object") { + const currentTermDeck = typeof terms.deck === "string" ? terms.deck.trim() : ""; + if (currentTermDeck !== targetDeck) { + terms.deck = targetDeck; + changed = true; + } + } + } + + if (!changed) { + return { updated: false, matched: true, reason: "already-target", currentServer, targetServer, targetDeck }; } - targetProfile.options.anki.server = targetServer; await invoke("setAllSettings", { value: optionsFull, source: "subminer" }); - return { updated: true, matched: true, currentServer, targetServer }; + return { updated: true, matched: true, currentServer, targetServer, targetDeck }; })(); `; diff --git a/src/main.ts b/src/main.ts index 2525d090..49457ba0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1833,6 +1833,31 @@ function getCurrentAutoplaySubtitlePayload(): SubtitleData | null { return payload; } +async function resolveSentenceSearchHeadwords(term: string): Promise { + const fallback = term.trim() ? [term.trim()] : []; + try { + const tokenized = tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(term) : null; + const tokens = tokenized?.tokens ?? []; + if (tokens.length === 0) return fallback; + + const seen = new Set(); + const headwords: string[] = []; + for (const token of tokens) { + const headword = (token.headword || token.surface).trim(); + if (!headword || seen.has(headword)) continue; + seen.add(headword); + headwords.push(headword); + } + return headwords.length > 0 ? headwords : fallback; + } catch (error) { + logger.debug( + 'Failed to resolve sentence-search headwords:', + error instanceof Error ? error.message : String(error), + ); + return fallback; + } +} + function signalCurrentSubtitleAutoplayReady(): void { autoplayReadyGate.flushPendingAutoplayReadySignal(); const payload = getCurrentAutoplaySubtitlePayload(); @@ -2240,6 +2265,18 @@ const configHotReloadRuntime = createConfigHotReloadRuntime( buildConfigHotReloadRuntimeMainDepsHandler(), ); +async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise { + await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); + return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), { + error: (message, ...args) => { + logger.error(message, ...args); + }, + info: (message, ...args) => { + logger.info(message, ...args); + }, + }); +} + const configSettingsRuntime = createConfigSettingsRuntime({ fields: configSettingsFields, getConfigPath: () => configService.getConfigPath(), @@ -2250,17 +2287,7 @@ const configSettingsRuntime = createConfigSettingsRuntime({ onHotReloadApplied: applyConfigHotReloadDiff, defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url, createAnkiClient: (url) => new AnkiConnectClient(url), - getYomitanAnkiDeckName: async () => { - await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); - return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), { - error: (message, ...args) => { - logger.error(message, ...args); - }, - info: (message, ...args) => { - logger.info(message, ...args); - }, - }); - }, + getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime, getSettingsWindow: () => appState.configSettingsWindow, setSettingsWindow: (window) => { appState.configSettingsWindow = window as BrowserWindow | null; @@ -4442,13 +4469,17 @@ const startLocalStatsServer = (): void => { knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), mpvSocketPath: appState.mpvSocketPath, getAnkiConnectConfig: () => getResolvedConfig().ankiConnect, + getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime, + getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, anilistRateLimiter, resolveAnkiNoteId: (noteId: number) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, + resolveSentenceSearchHeadwords, addYomitanNote: async (word: string) => { const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { forceOverride: true, + deck: getResolvedConfig().ankiConnect.deck, }); const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); if (result.noteId && result.duplicateNoteIds.length > 0) { @@ -5640,7 +5671,7 @@ async function ensureYomitanExtensionLoaded(): Promise { return extension; } -let lastSyncedYomitanAnkiServer: string | null = null; +let lastSyncedYomitanAnkiSettingsKey: string | null = null; function getPreferredYomitanAnkiServerUrl(): string { return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect); @@ -5671,7 +5702,10 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise { } const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); - if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) { + const ankiConnectConfig = getResolvedConfig().ankiConnect; + const targetDeck = ankiConnectConfig?.deck?.trim() ?? ''; + const targetSettingsKey = `${targetUrl}\n${targetDeck}`; + if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) { return; } @@ -5687,12 +5721,15 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise { }, }, { - forceOverride: shouldForceOverrideYomitanAnkiServer(getResolvedConfig().ankiConnect), + forceOverride: ankiConnectConfig + ? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig) + : false, + deck: targetDeck, }, ); if (synced) { - lastSyncedYomitanAnkiServer = targetUrl; + lastSyncedYomitanAnkiSettingsKey = targetSettingsKey; } } diff --git a/src/main/runtime/config-settings-runtime.test.ts b/src/main/runtime/config-settings-runtime.test.ts index e5906e94..77d2e714 100644 --- a/src/main/runtime/config-settings-runtime.test.ts +++ b/src/main/runtime/config-settings-runtime.test.ts @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { DEFAULT_CONFIG, deepCloneConfig } from '../../config'; +import { resolveConfig } from '../../config/resolve'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { createConfigSettingsRuntime } from './config-settings-runtime'; @@ -10,7 +14,13 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async fields: [], getConfigPath: () => '/tmp/config.jsonc', getRawConfig: () => ({}), - getConfig: () => deepCloneConfig(DEFAULT_CONFIG), + getConfig: () => ({ + ...deepCloneConfig(DEFAULT_CONFIG), + ankiConnect: { + ...deepCloneConfig(DEFAULT_CONFIG).ankiConnect, + deck: 'Configured', + }, + }), getWarnings: () => [], reloadConfigStrict: () => ({ @@ -48,3 +58,62 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async assert.ok(handler); assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' }); }); + +test('config settings runtime persists inferred Yomitan Anki deck when config deck is empty', async () => { + const handlers = new Map unknown>(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-')); + const configPath = path.join(dir, 'config.jsonc'); + fs.writeFileSync(configPath, '{"ankiConnect":{"deck":""}}\n', 'utf-8'); + + try { + let rawConfig = { ankiConnect: { deck: '' } }; + let resolvedConfig = resolveConfig(rawConfig).resolved; + const runtime = createConfigSettingsRuntime({ + fields: [], + getConfigPath: () => configPath, + getRawConfig: () => rawConfig, + getConfig: () => resolvedConfig, + getWarnings: () => [], + reloadConfigStrict: () => { + rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + resolvedConfig = resolveConfig(rawConfig).resolved; + return { + ok: true, + config: resolvedConfig, + warnings: [], + path: configPath, + }; + }, + getSettingsWindow: () => null, + setSettingsWindow: () => undefined, + createSettingsWindow: () => ({}) as never, + settingsHtmlPath: '/tmp/settings.html', + openPath: async () => '', + defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url, + createAnkiClient: () => + ({ + deckNames: async () => [], + fieldNamesForDeck: async () => [], + modelNamesForDeck: async () => [], + modelNames: async () => [], + modelFieldNames: async () => [], + }) as never, + getYomitanAnkiDeckName: async () => 'Minecraft', + ipcMain: { + handle: (channel, listener) => { + handlers.set(channel, listener); + }, + }, + ipcChannels: IPC_CHANNELS.request, + }); + + runtime.registerHandlers(); + + const handler = handlers.get(IPC_CHANNELS.request.getConfigSettingsYomitanAnkiDeckName); + assert.ok(handler); + assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Minecraft' }); + assert.equal(JSON.parse(fs.readFileSync(configPath, 'utf-8')).ankiConnect.deck, 'Minecraft'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/src/main/runtime/config-settings-runtime.ts b/src/main/runtime/config-settings-runtime.ts index 94a965e1..c0e063f3 100644 --- a/src/main/runtime/config-settings-runtime.ts +++ b/src/main/runtime/config-settings-runtime.ts @@ -193,13 +193,38 @@ export function createConfigSettingsRuntime { if (!deps.getYomitanAnkiDeckName) { return { ok: true, value: '' }; } try { const value = await deps.getYomitanAnkiDeckName(); - return { ok: true, value: typeof value === 'string' ? value.trim() : '' }; + const deckName = typeof value === 'string' ? value.trim() : ''; + persistInferredYomitanDeckIfEmpty(deckName); + return { ok: true, value: deckName }; } catch (error) { return { ok: false, diff --git a/src/stats-daemon-runner.ts b/src/stats-daemon-runner.ts index fd589c4d..c481acb2 100644 --- a/src/stats-daemon-runner.ts +++ b/src/stats-daemon-runner.ts @@ -15,6 +15,8 @@ import { import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command'; import { createInvokeStatsWordHelperHandler, + createReadStatsYomitanDeckNameHandler, + type StatsWordHelperSpawnOptions, type StatsWordHelperResponse, } from './stats-word-helper-client'; @@ -58,19 +60,22 @@ async function waitForWordHelperResponse(responsePath: string): Promise fs.mkdtempSync(path.join(os.tmpdir(), prefix)), - joinPath: (...parts) => path.join(...parts), - spawnHelper: async (options) => { +const statsWordHelperDeps = { + createTempDir: (prefix: string) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), + joinPath: (...parts: string[]) => path.join(...parts), + spawnHelper: async (options: StatsWordHelperSpawnOptions) => { const childArgs = [ options.scriptPath, '--stats-word-helper-response-path', options.responsePath, '--stats-word-helper-user-data-path', options.userDataPath, - '--stats-word-helper-word', - options.word, ]; + if (options.mode === 'deck-name') { + childArgs.push('--stats-word-helper-read-deck'); + } else { + childArgs.push('--stats-word-helper-word', options.word ?? ''); + } const logLevel = readFlagValue(process.argv, '--log-level'); if (logLevel) { childArgs.push('--log-level', logLevel); @@ -88,10 +93,13 @@ const invokeStatsWordHelper = createInvokeStatsWordHelperHandler({ }); }, waitForResponse: waitForWordHelperResponse, - removeDir: (targetPath) => { + removeDir: (targetPath: string) => { fs.rmSync(targetPath, { recursive: true, force: true }); }, -}); +}; + +const invokeStatsWordHelper = createInvokeStatsWordHelperHandler(statsWordHelperDeps); +const readStatsYomitanDeckName = createReadStatsYomitanDeckNameHandler(statsWordHelperDeps); const userDataPath = readFlagValue(process.argv, '--stats-user-data-path')?.trim(); const responsePath = readFlagValue(process.argv, '--stats-response-path')?.trim(); @@ -196,6 +204,13 @@ async function main(): Promise { tracker, knownWordCachePath, getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect, + getYomitanAnkiDeckName: async () => + await readStatsYomitanDeckName({ + helperScriptPath: wordHelperScriptPath, + userDataPath: daemonUserDataPath, + }), + getSecondarySubtitleLanguages: () => + configService.reloadConfig().secondarySub.secondarySubLanguages, addYomitanNote: async (word: string) => await invokeStatsWordHelper({ helperScriptPath: wordHelperScriptPath, diff --git a/src/stats-word-helper-client.test.ts b/src/stats-word-helper-client.test.ts index 6cb0e486..7ec4754f 100644 --- a/src/stats-word-helper-client.test.ts +++ b/src/stats-word-helper-client.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createInvokeStatsWordHelperHandler } from './stats-word-helper-client'; +import { + createInvokeStatsWordHelperHandler, + createReadStatsYomitanDeckNameHandler, +} from './stats-word-helper-client'; test('word helper client returns note id when helper responds before exit', async () => { const calls: string[] = []; @@ -36,6 +39,39 @@ test('word helper client returns note id when helper responds before exit', asyn ]); }); +test('word helper client returns Yomitan deck name from helper read-deck mode', async () => { + const calls: string[] = []; + const handler = createReadStatsYomitanDeckNameHandler({ + createTempDir: () => '/tmp/stats-word-helper', + joinPath: (...parts) => parts.join('/'), + spawnHelper: async (options) => { + calls.push( + `spawnHelper:${options.scriptPath}:${options.responsePath}:${options.userDataPath}:${options.mode}`, + ); + return new Promise((resolve) => setTimeout(() => resolve(0), 20)); + }, + waitForResponse: async (responsePath) => { + calls.push(`waitForResponse:${responsePath}`); + return { ok: true, deckName: ' Minecraft ' }; + }, + removeDir: (targetPath) => { + calls.push(`removeDir:${targetPath}`); + }, + }); + + const deckName = await handler({ + helperScriptPath: '/tmp/stats-word-helper.js', + userDataPath: '/tmp/SubMiner', + }); + + assert.equal(deckName, 'Minecraft'); + assert.deepEqual(calls, [ + 'spawnHelper:/tmp/stats-word-helper.js:/tmp/stats-word-helper/response.json:/tmp/SubMiner:deck-name', + 'waitForResponse:/tmp/stats-word-helper/response.json', + 'removeDir:/tmp/stats-word-helper', + ]); +}); + test('word helper client throws helper response errors', async () => { const handler = createInvokeStatsWordHelperHandler({ createTempDir: () => '/tmp/stats-word-helper', diff --git a/src/stats-word-helper-client.ts b/src/stats-word-helper-client.ts index 4ba068b8..45bb7c4e 100644 --- a/src/stats-word-helper-client.ts +++ b/src/stats-word-helper-client.ts @@ -1,18 +1,24 @@ export type StatsWordHelperResponse = { ok: boolean; noteId?: number; + deckName?: string; error?: string; }; +type StatsWordHelperMode = 'add-word' | 'deck-name'; + +export type StatsWordHelperSpawnOptions = { + scriptPath: string; + responsePath: string; + userDataPath: string; + mode: StatsWordHelperMode; + word?: string; +}; + export function createInvokeStatsWordHelperHandler(deps: { createTempDir: (prefix: string) => string; joinPath: (...parts: string[]) => string; - spawnHelper: (options: { - scriptPath: string; - responsePath: string; - userDataPath: string; - word: string; - }) => Promise; + spawnHelper: (options: StatsWordHelperSpawnOptions) => Promise; waitForResponse: (responsePath: string) => Promise; removeDir: (targetPath: string) => void; }) { @@ -29,6 +35,7 @@ export function createInvokeStatsWordHelperHandler(deps: { scriptPath: options.helperScriptPath, responsePath, userDataPath: options.userDataPath, + mode: 'add-word', word: options.word, }); @@ -64,3 +71,55 @@ export function createInvokeStatsWordHelperHandler(deps: { } }; } + +export function createReadStatsYomitanDeckNameHandler(deps: { + createTempDir: (prefix: string) => string; + joinPath: (...parts: string[]) => string; + spawnHelper: (options: StatsWordHelperSpawnOptions) => Promise; + waitForResponse: (responsePath: string) => Promise; + removeDir: (targetPath: string) => void; +}) { + return async (options: { helperScriptPath: string; userDataPath: string }): Promise => { + const tempDir = deps.createTempDir('subminer-stats-word-helper-'); + const responsePath = deps.joinPath(tempDir, 'response.json'); + + try { + const helperExitPromise = deps.spawnHelper({ + scriptPath: options.helperScriptPath, + responsePath, + userDataPath: options.userDataPath, + mode: 'deck-name', + }); + + const startupResult = await Promise.race([ + deps + .waitForResponse(responsePath) + .then((response) => ({ kind: 'response' as const, response })), + helperExitPromise.then((status) => ({ kind: 'exit' as const, status })), + ]); + + let response: StatsWordHelperResponse; + if (startupResult.kind === 'response') { + response = startupResult.response; + } else { + if (startupResult.status !== 0) { + throw new Error( + `Stats word helper exited before response (status ${startupResult.status}).`, + ); + } + response = await deps.waitForResponse(responsePath); + } + + const exitStatus = await helperExitPromise; + if (exitStatus !== 0) { + throw new Error(`Stats word helper exited with status ${exitStatus}.`); + } + if (!response.ok || typeof response.deckName !== 'string') { + throw new Error(response.error || 'Stats word helper failed.'); + } + return response.deckName.trim(); + } finally { + deps.removeDir(tempDir); + } + }; +} diff --git a/src/stats-word-helper.ts b/src/stats-word-helper.ts index 297b7c14..080dcc3d 100644 --- a/src/stats-word-helper.ts +++ b/src/stats-word-helper.ts @@ -7,6 +7,7 @@ import { createLogger, setLogLevel } from './logger'; import { loadYomitanExtension } from './core/services/yomitan-extension-loader'; import { addYomitanNoteViaSearch, + getYomitanCurrentAnkiDeckName, syncYomitanDefaultAnkiServer, } from './core/services/tokenizer/yomitan-parser-runtime'; import type { StatsWordHelperResponse } from './stats-word-helper-client'; @@ -54,13 +55,14 @@ function writeResponse(responsePath: string | undefined, payload: StatsWordHelpe const responsePath = readFlagValue(process.argv, '--stats-word-helper-response-path')?.trim(); const userDataPath = readFlagValue(process.argv, '--stats-word-helper-user-data-path')?.trim(); const word = readFlagValue(process.argv, '--stats-word-helper-word'); +const readDeck = process.argv.includes('--stats-word-helper-read-deck'); const logLevel = readFlagValue(process.argv, '--log-level'); if (logLevel) { setLogLevel(logLevel, 'cli'); } -if (!userDataPath || !word) { +if (!userDataPath || (!word && !readDeck)) { writeResponse(responsePath, { ok: false, error: 'Missing stats word helper arguments.', @@ -125,48 +127,42 @@ async function main(): Promise { throw new Error('Yomitan extension failed to load.'); } + const yomitanDeps = { + getYomitanExt: () => yomitanExt, + getYomitanSession: () => yomitanSession, + getYomitanParserWindow: () => yomitanParserWindow, + setYomitanParserWindow: (window: BrowserWindow | null) => { + yomitanParserWindow = window; + }, + getYomitanParserReadyPromise: () => yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise: Promise | null) => { + yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => yomitanParserInitPromise, + setYomitanParserInitPromise: (promise: Promise | null) => { + yomitanParserInitPromise = promise; + }, + }; + + if (readDeck) { + const deckName = await getYomitanCurrentAnkiDeckName(yomitanDeps, logger); + writeResponse(responsePath, { + ok: true, + deckName, + }); + cleanup(); + app.exit(0); + return; + } + await syncYomitanDefaultAnkiServer( config.ankiConnect?.url || 'http://127.0.0.1:8765', - { - getYomitanExt: () => yomitanExt, - getYomitanSession: () => yomitanSession, - getYomitanParserWindow: () => yomitanParserWindow, - setYomitanParserWindow: (window) => { - yomitanParserWindow = window; - }, - getYomitanParserReadyPromise: () => yomitanParserReadyPromise, - setYomitanParserReadyPromise: (promise) => { - yomitanParserReadyPromise = promise; - }, - getYomitanParserInitPromise: () => yomitanParserInitPromise, - setYomitanParserInitPromise: (promise) => { - yomitanParserInitPromise = promise; - }, - }, + yomitanDeps, logger, - { forceOverride: true }, + { forceOverride: true, deck: config.ankiConnect?.deck }, ); - const addResult = await addYomitanNoteViaSearch( - word!, - { - getYomitanExt: () => yomitanExt, - getYomitanSession: () => yomitanSession, - getYomitanParserWindow: () => yomitanParserWindow, - setYomitanParserWindow: (window) => { - yomitanParserWindow = window; - }, - getYomitanParserReadyPromise: () => yomitanParserReadyPromise, - setYomitanParserReadyPromise: (promise) => { - yomitanParserReadyPromise = promise; - }, - getYomitanParserInitPromise: () => yomitanParserInitPromise, - setYomitanParserInitPromise: (promise) => { - yomitanParserInitPromise = promise; - }, - }, - logger, - ); + const addResult = await addYomitanNoteViaSearch(word!, yomitanDeps, logger); const noteId = addResult.noteId; if (typeof noteId !== 'number') { diff --git a/stats/src/components/search/SearchTab.test.ts b/stats/src/components/search/SearchTab.test.ts index 35ab0bcd..27f822ce 100644 --- a/stats/src/components/search/SearchTab.test.ts +++ b/stats/src/components/search/SearchTab.test.ts @@ -3,11 +3,30 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { formatSentenceSearchMatchCountLabel } from './SearchTab'; const SEARCH_TAB_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'SearchTab.tsx'); +test('formatSentenceSearchMatchCountLabel uses singular label for one result', () => { + assert.equal(formatSentenceSearchMatchCountLabel(1), 'Match'); + assert.equal(formatSentenceSearchMatchCountLabel(0), 'Matches'); + assert.equal(formatSentenceSearchMatchCountLabel(2), 'Matches'); +}); + test('SearchTab forwards stored secondary subtitle text when mining from search results', () => { const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8'); assert.match(source, /secondaryText:\s*result\.secondaryText/); }); + +test('SearchTab enables headword sentence search by default and forwards the toggle', () => { + const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8'); + + assert.match( + source, + /const \[searchByHeadword,\s*setSearchByHeadword\] = useState\(true\);/, + ); + assert.match(source, /apiClient\s*\.\s*searchSentences\(trimmed,\s*SEARCH_LIMIT,\s*searchByHeadword\)/); + assert.match(source, /checked=\{searchByHeadword\}/); + assert.match(source, /setSearchByHeadword\(event\.target\.checked\)/); +}); diff --git a/stats/src/components/search/SearchTab.tsx b/stats/src/components/search/SearchTab.tsx index 243060a8..90853e95 100644 --- a/stats/src/components/search/SearchTab.tsx +++ b/stats/src/components/search/SearchTab.tsx @@ -41,8 +41,13 @@ function buttonLabel( return 'Sentence'; } +export function formatSentenceSearchMatchCountLabel(count: number): string { + return count === 1 ? 'Match' : 'Matches'; +} + export function SearchTab() { const [query, setQuery] = useState(''); + const [searchByHeadword, setSearchByHeadword] = useState(true); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -65,7 +70,7 @@ export function SearchTab() { setError(null); const timer = window.setTimeout(() => { apiClient - .searchSentences(trimmed, SEARCH_LIMIT) + .searchSentences(trimmed, SEARCH_LIMIT, searchByHeadword) .then((nextResults) => { if (requestId !== requestRef.current) return; setResults(nextResults); @@ -84,7 +89,7 @@ export function SearchTab() { return () => { window.clearTimeout(timer); }; - }, [query]); + }, [query, searchByHeadword]); const handleMine = async ( result: SentenceSearchResult, @@ -132,27 +137,37 @@ export function SearchTab() { return (
-
- -
-
+ + Sentence Search + +
+ setQuery(event.target.value)} + placeholder="Search sentence text or media..." + className="min-w-0 flex-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 text-base text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-yellow focus:outline-none" + autoComplete="off" + aria-label="Sentence search" + /> +
+
{loading ? '...' : results.length}
-
Matches
+
+ {loading ? 'Matches' : formatSentenceSearchMatchCountLabel(results.length)} +
+
{error && ( diff --git a/stats/src/components/vocabulary/WordDetailPanel.tsx b/stats/src/components/vocabulary/WordDetailPanel.tsx index ca189ee6..93beda63 100644 --- a/stats/src/components/vocabulary/WordDetailPanel.tsx +++ b/stats/src/components/vocabulary/WordDetailPanel.tsx @@ -301,7 +301,7 @@ export function WordDetailPanel({ {data.similarWords.length > 0 && (

- Similar Words + Related Seen Words

{data.similarWords.map((sw) => ( diff --git a/stats/src/lib/api-client.test.ts b/stats/src/lib/api-client.test.ts index 47eb1a0c..95956ceb 100644 --- a/stats/src/lib/api-client.test.ts +++ b/stats/src/lib/api-client.test.ts @@ -103,7 +103,13 @@ test('searchSentences encodes realtime sentence search requests', async () => { await apiClient.searchSentences('猫 食べる', 25); assert.equal( seenUrl, - `${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25`, + `${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=true`, + ); + + await apiClient.searchSentences('猫 食べる', 25, false); + assert.equal( + seenUrl, + `${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=false`, ); } finally { globalThis.fetch = originalFetch; diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index b85c8b97..6bc9713d 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -116,11 +116,12 @@ export const apiClient = { fetchJson( `/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`, ), - searchSentences: (query: string, limit = 50) => + searchSentences: (query: string, limit = 50, searchByHeadword = true) => fetchJson( `/api/stats/sentences/search?${new URLSearchParams({ q: query, limit: String(limit), + headword: String(searchByHeadword), }).toString()}`, ), getKanji: (limit = 100) => fetchJson(`/api/stats/kanji?limit=${limit}`),