feat: improve stats dashboard and annotation settings

This commit is contained in:
2026-03-15 21:18:35 -07:00
parent 650e95cdc3
commit 04682a02cc
75 changed files with 3420 additions and 619 deletions

View File

@@ -5,6 +5,8 @@
"": { "": {
"name": "subminer", "name": "subminer",
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@hono/node-server": "^1.19.11", "@hono/node-server": "^1.19.11",
"axios": "^1.13.5", "axios": "^1.13.5",
"commander": "^14.0.3", "commander": "^14.0.3",
@@ -98,6 +100,10 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],

View File

@@ -0,0 +1,5 @@
type: changed
area: anki
- Changed known-word cache settings to live under `ankiConnect.knownWords` instead of mixing them into `ankiConnect.nPlusOne`.
- Kept legacy `ankiConnect.nPlusOne` known-word keys and older `ankiConnect.behavior.nPlusOne*` keys as deprecated compatibility fallbacks.

View File

@@ -0,0 +1,4 @@
type: changed
area: stats
- Added session deletion to the Sessions tab with the same confirmation prompt used by anime episode/session deletes, and removed all associated session rows from the stats database.

View File

@@ -0,0 +1,4 @@
type: fixed
area: stats
- Fixed the in-app stats overlay so it connects to the configured `stats.serverPort` instead of falling back to the default port.

View File

@@ -0,0 +1,9 @@
type: fixed
area: overlay
- Fixed subtitle frequency tagging for merged lookup-backed tokens like `陰に` by falling back to exact surface-form Yomitan frequencies when the normalized headword lookup misses.
- Fixed MeCab merged-token position mapping across line breaks so merged content-plus-particle tokens like `陰に` keep their matched Yomitan frequency instead of inheriting shifted POS tags.
- Fixed grouped frequency parsing in both Yomitan and fallback frequency-dictionary lookups so display values like `118,121` use the leading rank instead of collapsing the rank and occurrence count into `118121`.
- Fixed frequency-rank ingestion to ignore Yomitan dictionaries explicitly marked `occurrence-based`, so raw occurrence counts are no longer treated as subtitle rank values.
- Fixed inflected headword frequency tagging to prefer ranks from the selected Yomitan `termsFind` popup entry itself, ordered by configured dictionary priority, so forms like `潜み` use primary-dictionary ranks like `4073` before falling back to lower-priority raw lemma metadata such as `CC100`.
- Fixed annotation-stage frequency filtering so exact kanji noun tokens like `者` keep their matched rank even when MeCab labels them `名詞/非自立`, instead of dropping the highlight after scan-time frequency lookup succeeds.

View File

@@ -343,6 +343,13 @@
"fallbackDuration": 3, // Fallback duration setting. "fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30 // Max media duration setting. "maxMediaDuration": 30 // Max media duration setting.
}, // Media setting. }, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
"decks": [], // Decks used for known-word cache scope. Supports one or more deck names.
"color": "#a6da95" // Color used for known-word highlights.
}, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false "overwriteAudio": true, // Overwrite audio setting. Values: true | false
"overwriteImage": true, // Overwrite image setting. Values: true | false "overwriteImage": true, // Overwrite image setting. Values: true | false
@@ -352,13 +359,8 @@
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"matchMode": "headword", // Known-word matching strategy for N+1 highlighting. Values: headword | surface
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. "nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
}, // N plus one setting. }, // N plus one setting.
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)" // Pattern setting. "pattern": "[SubMiner] %f (%t)" // Pattern setting.
@@ -512,7 +514,7 @@
// ========================================== // ==========================================
"stats": { "stats": {
"toggleKey": "Backquote", // Key code to toggle the stats overlay. "toggleKey": "Backquote", // Key code to toggle the stats overlay.
"serverPort": 5175, // Port for the stats HTTP server. "serverPort": 6969, // Port for the stats HTTP server.
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false "autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false "autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
} // Local immersion stats dashboard served on localhost and available as an in-app overlay. } // Local immersion stats dashboard served on localhost and available as an in-app overlay.

View File

@@ -665,10 +665,10 @@ Use the runtime options palette to toggle settings live while SubMiner is runnin
Current runtime options: Current runtime options:
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`) - `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`)
- `ankiConnect.nPlusOne.highlightEnabled` (`On` / `Off`) - `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`) - `subtitleStyle.enableJlpt` (`On` / `Off`)
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`) - `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
- `ankiConnect.nPlusOne.matchMode` (`headword` / `surface`) - `ankiConnect.knownWords.matchMode` (`headword` / `surface`)
- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`) - `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`)
Annotation toggles (`nPlusOne`, `enableJlpt`, `frequencyDictionary.enabled`) only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place. Annotation toggles (`nPlusOne`, `enableJlpt`, `frequencyDictionary.enabled`) only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
@@ -796,7 +796,7 @@ This example is intentionally compact. The option table below documents availabl
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | | `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). | | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `deck` | string | Anki deck to monitor for new cards | | `deck` | string | Anki deck to monitor for new cards |
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used for N+1 known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. | | `ankiConnect.knownWords.decks` | array of strings | Decks used for known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | | `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
@@ -823,13 +823,13 @@ This example is intentionally compact. The option table below documents availabl
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `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`) | | `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.nPlusOne.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | | `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
| `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` | array of strings | Decks used by known-word cache refresh. Leave empty for compatibility with legacy `deck` scope. |
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). | | `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
| `ankiConnect.nPlusOne.knownWord` | hex color string | Legacy known-word color kept for backward compatibility (default: `"#a6da95"`). |
| `ankiConnect.nPlusOne.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `ankiConnect.nPlusOne.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used by known-word cache refresh. Leave empty for compatibility with legacy `deck` scope. |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | | `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | | `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 | | `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
@@ -864,20 +864,20 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
### N+1 Word Highlighting ### N+1 Word Highlighting
When `ankiConnect.nPlusOne.highlightEnabled` is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering. When `ankiConnect.knownWords.highlightEnabled` is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering.
Known-word cache policy: Known-word cache policy:
- Initial sync runs when the integration starts if the cache is missing or stale. - Initial sync runs when the integration starts if the cache is missing or stale.
- `ankiConnect.nPlusOne.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki. - `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki.
- `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists. - `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists.
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`). - `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
- `ankiConnect.nPlusOne.knownWord` sets the legacy known-word highlight color for tokens already in Anki. - `ankiConnect.knownWords.color` sets the known-word highlight color for tokens already in Anki.
- `ankiConnect.nPlusOne.decks` accepts one or more decks. If empty, it uses the legacy single `ankiConnect.deck` value as scope. - `ankiConnect.knownWords.decks` accepts one or more decks. If empty, it uses the legacy single `ankiConnect.deck` value as scope.
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory. - Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes). - The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.nPlusOne.matchMode` to `"surface"` for raw subtitle text matching. - Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching.
- `ankiConnect.behavior.nPlusOne*` legacy keys (`nPlusOneHighlightEnabled`, `nPlusOneRefreshMinutes`, `nPlusOneMatchMode`) are deprecated and only kept for backward compatibility. - Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility.
- Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid. - Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid.
- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown. - If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown.
- Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts. - Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts.
@@ -887,9 +887,12 @@ To refresh roughly once per day, set:
```json ```json
{ {
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "knownWords": {
"highlightEnabled": true, "highlightEnabled": true,
"refreshMinutes": 1440 "refreshMinutes": 1440
},
"nPlusOne": {
"minSentenceWords": 3
} }
} }
} }

View File

@@ -343,6 +343,13 @@
"fallbackDuration": 3, // Fallback duration setting. "fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30 // Max media duration setting. "maxMediaDuration": 30 // Max media duration setting.
}, // Media setting. }, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
"decks": [], // Decks used for known-word cache scope. Supports one or more deck names.
"color": "#a6da95" // Color used for known-word highlights.
}, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false "overwriteAudio": true, // Overwrite audio setting. Values: true | false
"overwriteImage": true, // Overwrite image setting. Values: true | false "overwriteImage": true, // Overwrite image setting. Values: true | false
@@ -352,13 +359,8 @@
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"matchMode": "headword", // Known-word matching strategy for N+1 highlighting. Values: headword | surface
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. "nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
}, // N plus one setting. }, // N plus one setting.
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)" // Pattern setting. "pattern": "[SubMiner] %f (%t)" // Pattern setting.
@@ -512,7 +514,7 @@
// ========================================== // ==========================================
"stats": { "stats": {
"toggleKey": "Backquote", // Key code to toggle the stats overlay. "toggleKey": "Backquote", // Key code to toggle the stats overlay.
"serverPort": 5175, // Port for the stats HTTP server. "serverPort": 6969, // Port for the stats HTTP server.
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false "autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false "autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
} // Local immersion stats dashboard served on localhost and available as an in-app overlay. } // Local immersion stats dashboard served on localhost and available as an in-app overlay.

View File

@@ -2,7 +2,7 @@
SubMiner annotates subtitle tokens in real time as they appear in the overlay. Four annotation layers work together to surface useful context while you watch: **N+1 highlighting**, **character-name highlighting**, **frequency highlighting**, and **JLPT tagging**. SubMiner annotates subtitle tokens in real time as they appear in the overlay. Four annotation layers work together to surface useful context while you watch: **N+1 highlighting**, **character-name highlighting**, **frequency highlighting**, and **JLPT tagging**.
All four are opt-in and configured under `subtitleStyle` and `ankiConnect.nPlusOne` in your config. They apply independently — you can enable any combination. All four are opt-in and configured under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne` in your config. They apply independently — you can enable any combination.
## N+1 Word Highlighting ## N+1 Word Highlighting
@@ -20,13 +20,13 @@ N+1 highlighting identifies sentences where you know every word except one, maki
| Option | Default | Description | | Option | Default | Description |
| --- | --- | --- | | --- | --- | --- |
| `ankiConnect.nPlusOne.highlightEnabled` | `false` | Enable N+1 highlighting | | `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.nPlusOne.refreshMinutes` | `60` | Minutes between Anki cache refreshes | | `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.nPlusOne.decks` | `[]` | Decks to query (falls back to `ankiConnect.deck`) | | `ankiConnect.knownWords.decks` | `[]` | Decks to query (falls back to `ankiConnect.deck`) |
| `ankiConnect.nPlusOne.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) | | `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger | | `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word | | `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens | | `ankiConnect.knownWords.color` | `#a6da95` | Color for already-known tokens |
::: tip ::: tip
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large. Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
@@ -115,7 +115,7 @@ JLPT tagging requires the offline vocabulary bundle. See [JLPT Vocabulary Bundle
All annotation layers can be toggled at runtime via the mpv command menu without restarting: All annotation layers can be toggled at runtime via the mpv command menu without restarting:
- `ankiConnect.nPlusOne.highlightEnabled` (`On` / `Off`) - `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
- `subtitleStyle.nameMatchEnabled` (`On` / `Off`) - `subtitleStyle.nameMatchEnabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`) - `subtitleStyle.enableJlpt` (`On` / `Off`)
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`) - `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)

View File

@@ -37,7 +37,7 @@
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", "test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/mpv.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/mpv.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
@@ -84,6 +84,8 @@
"author": "", "author": "",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@hono/node-server": "^1.19.11", "@hono/node-server": "^1.19.11",
"axios": "^1.13.5", "axios": "^1.13.5",
"commander": "^14.0.3", "commander": "^14.0.3",

141
scripts/update-frequency.ts Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env bun
/**
* Backfill frequency_rank in imm_words from a Yomitan-format frequency dictionary.
*
* Usage:
* bun update-frequency.ts <path-to-frequency-dictionary-directory>
*
* The directory should contain term_meta_bank_*.json files (Yomitan format)
* and optionally an index.json with metadata.
*
* Example dictionaries: JPDB, BCCWJ, Innocent Corpus (in Yomitan format).
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import Database from 'libsql';
const DB_PATH = join(
process.env.HOME ?? '~',
'.config/SubMiner/immersion.sqlite',
);
function parsePositiveNumber(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return Math.floor(value);
}
function parseDisplayValue(value: unknown): number | null {
if (typeof value === 'string') {
const match = value.trim().match(/^\d+/)?.[0];
if (!match) return null;
const n = Number.parseInt(match, 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
return parsePositiveNumber(value);
}
function extractRank(meta: unknown): number | null {
if (!meta || typeof meta !== 'object') return null;
const freq = (meta as Record<string, unknown>).frequency;
if (!freq || typeof freq !== 'object') return null;
const f = freq as Record<string, unknown>;
return parseDisplayValue(f.displayValue) ?? parsePositiveNumber(f.value);
}
function loadDictionary(dirPath: string): Map<string, number> {
const terms = new Map<string, number>();
const files = readdirSync(dirPath)
.filter((f) => /^term_meta_bank.*\.json$/.test(f))
.sort();
if (files.length === 0) {
console.error(`No term_meta_bank_*.json files found in ${dirPath}`);
process.exit(1);
}
for (const file of files) {
const raw = JSON.parse(readFileSync(join(dirPath, file), 'utf-8')) as unknown[];
for (const entry of raw) {
if (!Array.isArray(entry) || entry.length < 3) continue;
const [term, , meta] = entry;
if (typeof term !== 'string') continue;
const rank = extractRank(meta);
if (rank === null) continue;
const normalized = term.trim().toLowerCase();
if (!normalized) continue;
const existing = terms.get(normalized);
if (existing === undefined || rank < existing) {
terms.set(normalized, rank);
}
}
console.log(` Loaded ${file} (${terms.size} terms total)`);
}
return terms;
}
function main() {
const dictPath = process.argv[2];
if (!dictPath) {
console.error('Usage: bun update-frequency.ts <path-to-frequency-dictionary-directory>');
console.error('');
console.error('The directory should contain Yomitan term_meta_bank_*.json files.');
console.error('Examples: JPDB, BCCWJ, Innocent Corpus frequency lists.');
process.exit(1);
}
if (!existsSync(dictPath)) {
console.error(`Directory not found: ${dictPath}`);
process.exit(1);
}
if (!existsSync(DB_PATH)) {
console.error(`Database not found: ${DB_PATH}`);
process.exit(1);
}
console.log(`Loading frequency dictionary from ${dictPath}...`);
const dict = loadDictionary(dictPath);
console.log(`Loaded ${dict.size} terms from frequency dictionary.\n`);
console.log(`Opening database: ${DB_PATH}`);
const db = new Database(DB_PATH);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
const words = db.prepare('SELECT id, headword, word FROM imm_words').all() as Array<{
id: number;
headword: string;
word: string;
}>;
console.log(`Found ${words.length} words in imm_words.\n`);
const updateStmt = db.prepare(
'UPDATE imm_words SET frequency_rank = ? WHERE id = ? AND (frequency_rank IS NULL OR frequency_rank > ?)',
);
let updated = 0;
let matched = 0;
for (const w of words) {
const headwordNorm = w.headword.trim().toLowerCase();
const wordNorm = w.word.trim().toLowerCase();
const rank = dict.get(headwordNorm) ?? dict.get(wordNorm) ?? null;
if (rank === null) continue;
matched++;
const result = updateStmt.run(rank, w.id, rank);
if (result.changes > 0) updated++;
}
console.log(`Matched: ${matched}/${words.length} words found in frequency dictionary`);
console.log(`Updated: ${updated} rows with new or better frequency_rank`);
db.close();
console.log('Done.');
}
main();

View File

@@ -56,7 +56,7 @@ function createIntegrationTestContext(
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
{ {
nPlusOne: { knownWords: {
highlightEnabled: options.highlightEnabled ?? true, highlightEnabled: options.highlightEnabled ?? true,
}, },
}, },

View File

@@ -465,11 +465,11 @@ export class AnkiIntegration {
} }
getKnownWordMatchMode(): NPlusOneMatchMode { getKnownWordMatchMode(): NPlusOneMatchMode {
return this.config.nPlusOne?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.matchMode; return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
} }
private isKnownWordCacheEnabled(): boolean { private isKnownWordCacheEnabled(): boolean {
return this.config.nPlusOne?.highlightEnabled === true; return this.config.knownWords?.highlightEnabled === true;
} }
private getConfiguredAnkiTags(): string[] { private getConfiguredAnkiTags(): string[] {

View File

@@ -203,32 +203,34 @@ export class KnownWordCacheManager {
} }
private isKnownWordCacheEnabled(): boolean { private isKnownWordCacheEnabled(): boolean {
return this.deps.getConfig().nPlusOne?.highlightEnabled === true; return this.deps.getConfig().knownWords?.highlightEnabled === true;
} }
private getKnownWordRefreshIntervalMs(): number { private getKnownWordRefreshIntervalMs(): number {
const minutes = this.deps.getConfig().nPlusOne?.refreshMinutes; const minutes = this.deps.getConfig().knownWords?.refreshMinutes;
const safeMinutes = const safeMinutes =
typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0 typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0
? minutes ? minutes
: DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.refreshMinutes; : DEFAULT_ANKI_CONNECT_CONFIG.knownWords.refreshMinutes;
return safeMinutes * 60_000; return safeMinutes * 60_000;
} }
private getKnownWordDecks(): string[] { private getKnownWordDecks(): string[] {
const configuredDecks = this.deps.getConfig().nPlusOne?.decks; const configuredDecks = this.deps.getConfig().knownWords?.decks;
if (Array.isArray(configuredDecks)) { if (Array.isArray(configuredDecks)) {
const decks = configuredDecks return configuredDecks
.filter((entry): entry is string => typeof entry === 'string') .map((deck) => (typeof deck === 'string' ? deck.trim() : ''))
.map((entry) => entry.trim()) .filter((deck) => deck.length > 0);
.filter((entry) => entry.length > 0);
return [...new Set(decks)];
} }
const deck = this.deps.getConfig().deck?.trim(); const deck = this.deps.getConfig().deck?.trim();
return deck ? [deck] : []; return deck ? [deck] : [];
} }
private getConfiguredFields(): string[] {
return ['Expression', 'Word', 'Reading', 'Word Reading'];
}
private buildKnownWordsQuery(): string { private buildKnownWordsQuery(): string {
const decks = this.getKnownWordDecks(); const decks = this.getKnownWordDecks();
if (decks.length === 0) { if (decks.length === 0) {
@@ -344,8 +346,8 @@ export class KnownWordCacheManager {
private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] { private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
const words: string[] = []; const words: string[] = [];
const preferredFields = ['Expression', 'Word']; const configuredFields = this.getConfiguredFields();
for (const preferredField of preferredFields) { for (const preferredField of configuredFields) {
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField); const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
if (!fieldName) continue; if (!fieldName) continue;

View File

@@ -78,7 +78,7 @@ test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled',
test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => { test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => {
const { runtime, calls } = createRuntime({ const { runtime, calls } = createRuntime({
nPlusOne: { knownWords: {
highlightEnabled: true, highlightEnabled: true,
}, },
pollingRate: 250, pollingRate: 250,
@@ -88,7 +88,7 @@ test('AnkiIntegrationRuntime switches transports and clears known words when run
calls.length = 0; calls.length = 0;
runtime.applyRuntimeConfigPatch({ runtime.applyRuntimeConfigPatch({
nPlusOne: { knownWords: {
highlightEnabled: false, highlightEnabled: false,
}, },
proxy: { proxy: {

View File

@@ -86,6 +86,14 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
...DEFAULT_ANKI_CONNECT_CONFIG.media, ...DEFAULT_ANKI_CONNECT_CONFIG.media,
...(config.media ?? {}), ...(config.media ?? {}),
}, },
knownWords: {
...DEFAULT_ANKI_CONNECT_CONFIG.knownWords,
...(config.knownWords ?? {}),
},
nPlusOne: {
...DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne,
...(config.nPlusOne ?? {}),
},
behavior: { behavior: {
...DEFAULT_ANKI_CONNECT_CONFIG.behavior, ...DEFAULT_ANKI_CONNECT_CONFIG.behavior,
...(config.behavior ?? {}), ...(config.behavior ?? {}),
@@ -136,12 +144,19 @@ export class AnkiIntegrationRuntime {
} }
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void { applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
const wasKnownWordCacheEnabled = this.config.nPlusOne?.highlightEnabled === true; const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
const previousTransportKey = this.getTransportConfigKey(this.config); const previousTransportKey = this.getTransportConfigKey(this.config);
const mergedConfig: AnkiConnectConfig = { const mergedConfig: AnkiConnectConfig = {
...this.config, ...this.config,
...patch, ...patch,
knownWords:
patch.knownWords !== undefined
? {
...(this.config.knownWords ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords),
...patch.knownWords,
}
: this.config.knownWords,
nPlusOne: nPlusOne:
patch.nPlusOne !== undefined patch.nPlusOne !== undefined
? { ? {
@@ -177,7 +192,7 @@ export class AnkiIntegrationRuntime {
this.config = normalizeAnkiIntegrationConfig(mergedConfig); this.config = normalizeAnkiIntegrationConfig(mergedConfig);
this.deps.onConfigChanged?.(this.config); this.deps.onConfigChanged?.(this.config);
if (wasKnownWordCacheEnabled && this.config.nPlusOne?.highlightEnabled === false) { if (wasKnownWordCacheEnabled && this.config.knownWords?.highlightEnabled === false) {
this.deps.knownWordCache.stopLifecycle(); this.deps.knownWordCache.stopLifecycle();
this.deps.knownWordCache.clearKnownWordCacheState(); this.deps.knownWordCache.clearKnownWordCacheState();
} else { } else {

View File

@@ -1363,13 +1363,13 @@ test('runtime options registry is centralized', () => {
]); ]);
}); });
test('validates ankiConnect n+1 behavior values', () => { test('validates ankiConnect knownWords behavior values', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "knownWords": {
"highlightEnabled": "yes", "highlightEnabled": "yes",
"refreshMinutes": -5 "refreshMinutes": -5
} }
@@ -1383,24 +1383,24 @@ test('validates ankiConnect n+1 behavior values', () => {
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.equal( assert.equal(
config.ankiConnect.nPlusOne.highlightEnabled, config.ankiConnect.knownWords.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
); );
assert.equal( assert.equal(
config.ankiConnect.nPlusOne.refreshMinutes, config.ankiConnect.knownWords.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes, DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
); );
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.refreshMinutes')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.refreshMinutes'));
}); });
test('accepts valid ankiConnect n+1 behavior values', () => { test('accepts valid ankiConnect knownWords behavior values', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "knownWords": {
"highlightEnabled": true, "highlightEnabled": true,
"refreshMinutes": 120 "refreshMinutes": 120
} }
@@ -1412,8 +1412,8 @@ test('accepts valid ankiConnect n+1 behavior values', () => {
const service = new ConfigService(dir); const service = new ConfigService(dir);
const config = service.getConfig(); const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true); assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 120); assert.equal(config.ankiConnect.knownWords.refreshMinutes, 120);
}); });
test('validates ankiConnect n+1 minimum sentence word count', () => { test('validates ankiConnect n+1 minimum sentence word count', () => {
@@ -1461,13 +1461,13 @@ test('accepts valid ankiConnect n+1 minimum sentence word count', () => {
assert.equal(config.ankiConnect.nPlusOne.minSentenceWords, 4); assert.equal(config.ankiConnect.nPlusOne.minSentenceWords, 4);
}); });
test('validates ankiConnect n+1 match mode values', () => { test('validates ankiConnect knownWords match mode values', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "knownWords": {
"matchMode": "bad-mode" "matchMode": "bad-mode"
} }
} }
@@ -1480,19 +1480,19 @@ test('validates ankiConnect n+1 match mode values', () => {
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.equal( assert.equal(
config.ankiConnect.nPlusOne.matchMode, config.ankiConnect.knownWords.matchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
); );
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.matchMode')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.matchMode'));
}); });
test('accepts valid ankiConnect n+1 match mode values', () => { test('accepts valid ankiConnect knownWords match mode values', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "knownWords": {
"matchMode": "surface" "matchMode": "surface"
} }
} }
@@ -1503,18 +1503,20 @@ test('accepts valid ankiConnect n+1 match mode values', () => {
const service = new ConfigService(dir); const service = new ConfigService(dir);
const config = service.getConfig(); const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface'); assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
}); });
test('validates ankiConnect n+1 color values', () => { test('validates ankiConnect knownWords and n+1 color values', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "nPlusOne": {
"nPlusOne": "not-a-color", "nPlusOne": "not-a-color"
"knownWord": 123 },
"knownWords": {
"color": 123
} }
} }
}`, }`,
@@ -1527,22 +1529,24 @@ test('validates ankiConnect n+1 color values', () => {
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne); assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
assert.equal( assert.equal(
config.ankiConnect.nPlusOne.knownWord, config.ankiConnect.knownWords.color,
DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord, DEFAULT_CONFIG.ankiConnect.knownWords.color,
); );
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.knownWord')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
}); });
test('accepts valid ankiConnect n+1 color values', () => { test('accepts valid ankiConnect knownWords and n+1 color values', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "nPlusOne": {
"nPlusOne": "#c6a0f6", "nPlusOne": "#c6a0f6"
"knownWord": "#a6da95" },
"knownWords": {
"color": "#a6da95"
} }
} }
}`, }`,
@@ -1553,7 +1557,46 @@ test('accepts valid ankiConnect n+1 color values', () => {
const config = service.getConfig(); const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6'); assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
assert.equal(config.ankiConnect.nPlusOne.knownWord, '#a6da95'); assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
});
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
"refreshMinutes": 90,
"matchMode": "surface",
"decks": ["Mining", "Kaishi 1.5k"],
"knownWord": "#a6da95"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(config.ankiConnect.knownWords.decks, ['Mining', 'Kaishi 1.5k']);
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
assert.ok(
warnings.some(
(warning) =>
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
warning.path === 'ankiConnect.nPlusOne.decks' ||
warning.path === 'ankiConnect.nPlusOne.knownWord',
),
);
}); });
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => { test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
@@ -1576,9 +1619,9 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const config = service.getConfig(); const config = service.getConfig();
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true); assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 90); assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface'); assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.ok( assert.ok(
warnings.some( warnings.some(
(warning) => (warning) =>
@@ -1799,13 +1842,13 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
); );
}); });
test('accepts valid ankiConnect n+1 deck list', () => { test('accepts valid ankiConnect knownWords deck list', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "knownWords": {
"decks": ["Deck One", "Deck Two"] "decks": ["Deck One", "Deck Two"]
} }
} }
@@ -1816,7 +1859,7 @@ test('accepts valid ankiConnect n+1 deck list', () => {
const service = new ConfigService(dir); const service = new ConfigService(dir);
const config = service.getConfig(); const config = service.getConfig();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, ['Deck One', 'Deck Two']); assert.deepEqual(config.ankiConnect.knownWords.decks, ['Deck One', 'Deck Two']);
}); });
test('accepts valid ankiConnect tags list', () => { test('accepts valid ankiConnect tags list', () => {
@@ -1857,13 +1900,13 @@ test('falls back to default when ankiConnect tags list is invalid', () => {
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags'));
}); });
test('falls back to default when ankiConnect n+1 deck list is invalid', () => { test('falls back to default when ankiConnect knownWords deck list is invalid', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
`{ `{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "knownWords": {
"decks": "not-an-array" "decks": "not-an-array"
} }
} }
@@ -1875,8 +1918,8 @@ test('falls back to default when ankiConnect n+1 deck list is invalid', () => {
const config = service.getConfig(); const config = service.getConfig();
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, []); assert.deepEqual(config.ankiConnect.knownWords.decks, []);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'));
}); });
test('template generator includes known keys', () => { test('template generator includes known keys', () => {
@@ -1891,9 +1934,10 @@ test('template generator includes known keys', () => {
assert.match(output, /"youtubeSubgen":/); assert.match(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/); assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/); assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"knownWords"\s*:\s*\{/);
assert.match(output, /"color": "#a6da95"/);
assert.match(output, /"nPlusOne"\s*:\s*\{/); assert.match(output, /"nPlusOne"\s*:\s*\{/);
assert.match(output, /"nPlusOne": "#c6a0f6"/); assert.match(output, /"nPlusOne": "#c6a0f6"/);
assert.match(output, /"knownWord": "#a6da95"/);
assert.match(output, /"minSentenceWords": 3/); assert.match(output, /"minSentenceWords": 3/);
assert.match(output, /auto-generated from src\/config\/definitions.ts/); assert.match(output, /auto-generated from src\/config\/definitions.ts/);
assert.match( assert.match(

View File

@@ -50,6 +50,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
fallbackDuration: 3.0, fallbackDuration: 3.0,
maxMediaDuration: 30, maxMediaDuration: 30,
}, },
knownWords: {
highlightEnabled: false,
refreshMinutes: 1440,
matchMode: 'headword',
decks: [],
color: '#a6da95',
},
behavior: { behavior: {
overwriteAudio: true, overwriteAudio: true,
overwriteImage: true, overwriteImage: true,
@@ -59,13 +66,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
autoUpdateNewCards: true, autoUpdateNewCards: true,
}, },
nPlusOne: { nPlusOne: {
highlightEnabled: false,
refreshMinutes: 1440,
matchMode: 'headword',
decks: [],
minSentenceWords: 3, minSentenceWords: 3,
nPlusOne: '#c6a0f6', nPlusOne: '#c6a0f6',
knownWord: '#a6da95',
}, },
metadata: { metadata: {
pattern: '[SubMiner] %f (%t)', pattern: '[SubMiner] %f (%t)',

View File

@@ -3,7 +3,7 @@ import { ResolvedConfig } from '../../types.js';
export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = { export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
stats: { stats: {
toggleKey: 'Backquote', toggleKey: 'Backquote',
serverPort: 5175, serverPort: 6969,
autoStartServer: true, autoStartServer: true,
autoOpenBrowser: true, autoOpenBrowser: true,
}, },

View File

@@ -77,22 +77,22 @@ export function buildIntegrationConfigOptionRegistry(
runtime: runtimeOptionById.get('anki.autoUpdateNewCards'), runtime: runtimeOptionById.get('anki.autoUpdateNewCards'),
}, },
{ {
path: 'ankiConnect.nPlusOne.matchMode', path: 'ankiConnect.knownWords.matchMode',
kind: 'enum', kind: 'enum',
enumValues: ['headword', 'surface'], enumValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode, defaultValue: defaultConfig.ankiConnect.knownWords.matchMode,
description: 'Known-word matching strategy for N+1 highlighting.', description: 'Known-word matching strategy for subtitle annotations.',
}, },
{ {
path: 'ankiConnect.nPlusOne.highlightEnabled', path: 'ankiConnect.knownWords.highlightEnabled',
kind: 'boolean', kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled, defaultValue: defaultConfig.ankiConnect.knownWords.highlightEnabled,
description: 'Enable fast local highlighting for words already known in Anki.', description: 'Enable fast local highlighting for words already known in Anki.',
}, },
{ {
path: 'ankiConnect.nPlusOne.refreshMinutes', path: 'ankiConnect.knownWords.refreshMinutes',
kind: 'number', kind: 'number',
defaultValue: defaultConfig.ankiConnect.nPlusOne.refreshMinutes, defaultValue: defaultConfig.ankiConnect.knownWords.refreshMinutes,
description: 'Minutes between known-word cache refreshes.', description: 'Minutes between known-word cache refreshes.',
}, },
{ {
@@ -102,10 +102,10 @@ export function buildIntegrationConfigOptionRegistry(
description: 'Minimum sentence word count required for N+1 targeting (default: 3).', description: 'Minimum sentence word count required for N+1 targeting (default: 3).',
}, },
{ {
path: 'ankiConnect.nPlusOne.decks', path: 'ankiConnect.knownWords.decks',
kind: 'array', kind: 'array',
defaultValue: defaultConfig.ankiConnect.nPlusOne.decks, defaultValue: defaultConfig.ankiConnect.knownWords.decks,
description: 'Decks used for N+1 known-word cache scope. Supports one or more deck names.', description: 'Decks used for known-word cache scope. Supports one or more deck names.',
}, },
{ {
path: 'ankiConnect.nPlusOne.nPlusOne', path: 'ankiConnect.nPlusOne.nPlusOne',
@@ -114,10 +114,10 @@ export function buildIntegrationConfigOptionRegistry(
description: 'Color used for the single N+1 target token highlight.', description: 'Color used for the single N+1 target token highlight.',
}, },
{ {
path: 'ankiConnect.nPlusOne.knownWord', path: 'ankiConnect.knownWords.color',
kind: 'string', kind: 'string',
defaultValue: defaultConfig.ankiConnect.nPlusOne.knownWord, defaultValue: defaultConfig.ankiConnect.knownWords.color,
description: 'Color used for legacy known-word highlights.', description: 'Color used for known-word highlights.',
}, },
{ {
path: 'ankiConnect.isKiku.fieldGrouping', path: 'ankiConnect.isKiku.fieldGrouping',

View File

@@ -21,15 +21,19 @@ export function buildRuntimeOptionRegistry(
}, },
{ {
id: 'subtitle.annotation.nPlusOne', id: 'subtitle.annotation.nPlusOne',
path: 'ankiConnect.nPlusOne.highlightEnabled', path: 'ankiConnect.knownWords.highlightEnabled',
label: 'N+1 Annotation', label: 'N+1 Annotation',
scope: 'subtitle', scope: 'subtitle',
valueType: 'boolean', valueType: 'boolean',
allowedValues: [true, false], allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled, defaultValue: defaultConfig.ankiConnect.knownWords.highlightEnabled,
requiresRestart: false, requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'), formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: () => ({}), toAnkiPatch: (value) => ({
knownWords: {
highlightEnabled: value === true,
},
}),
}, },
{ {
id: 'subtitle.annotation.jlpt', id: 'subtitle.annotation.jlpt',
@@ -57,16 +61,16 @@ export function buildRuntimeOptionRegistry(
}, },
{ {
id: 'anki.nPlusOneMatchMode', id: 'anki.nPlusOneMatchMode',
path: 'ankiConnect.nPlusOne.matchMode', path: 'ankiConnect.knownWords.matchMode',
label: 'N+1 Match Mode', label: 'Known Word Match Mode',
scope: 'ankiConnect', scope: 'ankiConnect',
valueType: 'enum', valueType: 'enum',
allowedValues: ['headword', 'surface'], allowedValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode, defaultValue: defaultConfig.ankiConnect.knownWords.matchMode,
requiresRestart: false, requiresRestart: false,
formatValueForOsd: (value) => String(value), formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({ toAnkiPatch: (value) => ({
nPlusOne: { knownWords: {
matchMode: value === 'headword' || value === 'surface' ? value : 'headword', matchMode: value === 'headword' || value === 'surface' ? value : 'headword',
}, },
}), }),

View File

@@ -20,23 +20,20 @@ function makeContext(ankiConnect: unknown): {
return { context, warnings }; return { context, warnings };
} }
test('modern invalid nPlusOne.highlightEnabled warns modern key and does not fallback to legacy', () => { test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
const { context, warnings } = makeContext({ const { context, warnings } = makeContext({
behavior: { nPlusOneHighlightEnabled: true }, nPlusOne: { highlightEnabled: true },
nPlusOne: { highlightEnabled: 'yes' }, knownWords: { highlightEnabled: 'yes' },
}); });
applyAnkiConnectResolution(context); applyAnkiConnectResolution(context);
assert.equal( assert.equal(
context.resolved.ankiConnect.nPlusOne.highlightEnabled, context.resolved.ankiConnect.knownWords.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'));
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.behavior.nPlusOneHighlightEnabled'),
false,
); );
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled'));
assert.equal(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'), false);
}); });
test('normalizes ankiConnect tags by trimming and deduping', () => { test('normalizes ankiConnect tags by trimming and deduping', () => {
@@ -53,18 +50,18 @@ test('normalizes ankiConnect tags by trimming and deduping', () => {
); );
}); });
test('warns and falls back for invalid nPlusOne.decks entries', () => { test('warns and falls back for invalid knownWords.decks entries', () => {
const { context, warnings } = makeContext({ const { context, warnings } = makeContext({
nPlusOne: { decks: ['Core Deck', 123] }, knownWords: { decks: ['Core Deck', 123] },
}); });
applyAnkiConnectResolution(context); applyAnkiConnectResolution(context);
assert.deepEqual( assert.deepEqual(
context.resolved.ankiConnect.nPlusOne.decks, context.resolved.ankiConnect.knownWords.decks,
DEFAULT_CONFIG.ankiConnect.nPlusOne.decks, DEFAULT_CONFIG.ankiConnect.knownWords.decks,
); );
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'));
}); });
test('accepts valid proxy settings', () => { test('accepts valid proxy settings', () => {

View File

@@ -42,12 +42,13 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
]); ]);
const { const {
knownWords: _knownWordsConfigFromAnkiConnect,
nPlusOne: _nPlusOneConfigFromAnkiConnect, nPlusOne: _nPlusOneConfigFromAnkiConnect,
ai: _ankiAiConfig, ai: _ankiAiConfig,
...ankiConnectWithoutNPlusOne ...ankiConnectWithoutKnownWordsOrNPlusOne
} = ac as Record<string, unknown>; } = ac as Record<string, unknown>;
const ankiConnectWithoutLegacy = Object.fromEntries( const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)), Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !legacyKeys.has(key)),
); );
context.resolved.ankiConnect = { context.resolved.ankiConnect = {
@@ -67,6 +68,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
? (ac.media as (typeof context.resolved)['ankiConnect']['media']) ? (ac.media as (typeof context.resolved)['ankiConnect']['media'])
: {}), : {}),
}, },
knownWords: {
...context.resolved.ankiConnect.knownWords,
},
behavior: { behavior: {
...context.resolved.ankiConnect.behavior, ...context.resolved.ankiConnect.behavior,
...(isObject(ac.behavior) ...(isObject(ac.behavior)
@@ -620,81 +624,126 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
); );
} }
const knownWordsConfig = isObject(ac.knownWords) ? (ac.knownWords as Record<string, unknown>) : {};
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {}; const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
const nPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled); const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
if (nPlusOneHighlightEnabled !== undefined) { const legacyNPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
context.resolved.ankiConnect.nPlusOne.highlightEnabled = nPlusOneHighlightEnabled; if (knownWordsHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
} else if (knownWordsConfig.highlightEnabled !== undefined) {
context.warn(
'ankiConnect.knownWords.highlightEnabled',
knownWordsConfig.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else if (legacyNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = legacyNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else if (nPlusOneConfig.highlightEnabled !== undefined) { } else if (nPlusOneConfig.highlightEnabled !== undefined) {
context.warn( context.warn(
'ankiConnect.nPlusOne.highlightEnabled', 'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled, nPlusOneConfig.highlightEnabled,
context.resolved.ankiConnect.nPlusOne.highlightEnabled, context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.', 'Expected boolean.',
); );
context.resolved.ankiConnect.nPlusOne.highlightEnabled = context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else { } else {
const legacyNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled); const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyNPlusOneHighlightEnabled !== undefined) { if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.highlightEnabled = legacyNPlusOneHighlightEnabled; context.resolved.ankiConnect.knownWords.highlightEnabled =
legacyBehaviorNPlusOneHighlightEnabled;
context.warn( context.warn(
'ankiConnect.behavior.nPlusOneHighlightEnabled', 'ankiConnect.behavior.nPlusOneHighlightEnabled',
behavior.nPlusOneHighlightEnabled, behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled', 'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
); );
} else { } else {
context.resolved.ankiConnect.nPlusOne.highlightEnabled = context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} }
} }
const nPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes); const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const hasValidNPlusOneRefreshMinutes = const legacyNPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
nPlusOneRefreshMinutes !== undefined && const hasValidKnownWordsRefreshMinutes =
Number.isInteger(nPlusOneRefreshMinutes) && knownWordsRefreshMinutes !== undefined &&
nPlusOneRefreshMinutes > 0; Number.isInteger(knownWordsRefreshMinutes) &&
if (nPlusOneRefreshMinutes !== undefined) { knownWordsRefreshMinutes > 0;
if (hasValidNPlusOneRefreshMinutes) { const hasValidLegacyNPlusOneRefreshMinutes =
context.resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes; legacyNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
legacyNPlusOneRefreshMinutes > 0;
if (knownWordsRefreshMinutes !== undefined) {
if (hasValidKnownWordsRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
} else {
context.warn(
'ankiConnect.knownWords.refreshMinutes',
knownWordsConfig.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (legacyNPlusOneRefreshMinutes !== undefined) {
if (hasValidLegacyNPlusOneRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else { } else {
context.warn( context.warn(
'ankiConnect.nPlusOne.refreshMinutes', 'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes, nPlusOneConfig.refreshMinutes,
context.resolved.ankiConnect.nPlusOne.refreshMinutes, context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.', 'Expected a positive integer.',
); );
context.resolved.ankiConnect.nPlusOne.refreshMinutes = context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
} }
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) { } else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
const legacyNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes); const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const hasValidLegacyRefreshMinutes = const hasValidLegacyRefreshMinutes =
legacyNPlusOneRefreshMinutes !== undefined && legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyNPlusOneRefreshMinutes) && Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
legacyNPlusOneRefreshMinutes > 0; legacyBehaviorNPlusOneRefreshMinutes > 0;
if (hasValidLegacyRefreshMinutes) { if (hasValidLegacyRefreshMinutes) {
context.resolved.ankiConnect.nPlusOne.refreshMinutes = legacyNPlusOneRefreshMinutes; context.resolved.ankiConnect.knownWords.refreshMinutes =
legacyBehaviorNPlusOneRefreshMinutes;
context.warn( context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes', 'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes, behavior.nPlusOneRefreshMinutes,
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes, DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes', 'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
); );
} else { } else {
context.warn( context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes', 'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes, behavior.nPlusOneRefreshMinutes,
context.resolved.ankiConnect.nPlusOne.refreshMinutes, context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.', 'Expected a positive integer.',
); );
context.resolved.ankiConnect.nPlusOne.refreshMinutes = context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
} }
} else { } else {
context.resolved.ankiConnect.nPlusOne.refreshMinutes = context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
} }
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords); const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
@@ -720,72 +769,137 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
} }
const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode); const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode); const legacyNPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
const hasValidNPlusOneMatchMode = const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
nPlusOneMatchMode === 'headword' || nPlusOneMatchMode === 'surface'; const hasValidKnownWordsMatchMode =
const hasValidLegacyMatchMode = knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
const hasValidLegacyNPlusOneMatchMode =
legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface'; legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
if (hasValidNPlusOneMatchMode) { const hasValidLegacyMatchMode =
context.resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode; legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
} else if (nPlusOneMatchMode !== undefined) { if (hasValidKnownWordsMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
} else if (knownWordsMatchMode !== undefined) {
context.warn(
'ankiConnect.knownWords.matchMode',
knownWordsConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} else if (legacyNPlusOneMatchMode !== undefined) {
if (hasValidLegacyNPlusOneMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyNPlusOneMatchMode;
context.warn( context.warn(
'ankiConnect.nPlusOne.matchMode', 'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode, nPlusOneConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.", "Expected 'headword' or 'surface'.",
); );
context.resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; context.resolved.ankiConnect.knownWords.matchMode =
} else if (legacyNPlusOneMatchMode !== undefined) { DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) { if (hasValidLegacyMatchMode) {
context.resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode; context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
context.warn( context.warn(
'ankiConnect.behavior.nPlusOneMatchMode', 'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode, behavior.nPlusOneMatchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode', 'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
); );
} else { } else {
context.warn( context.warn(
'ankiConnect.behavior.nPlusOneMatchMode', 'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode, behavior.nPlusOneMatchMode,
context.resolved.ankiConnect.nPlusOne.matchMode, context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.", "Expected 'headword' or 'surface'.",
); );
context.resolved.ankiConnect.nPlusOne.matchMode = context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} }
} else { } else {
context.resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} }
const nPlusOneDecks = nPlusOneConfig.decks; const knownWordsDecks = knownWordsConfig.decks;
if (Array.isArray(nPlusOneDecks)) { const legacyNPlusOneDecks = nPlusOneConfig.decks;
const normalizedDecks = nPlusOneDecks if (Array.isArray(knownWordsDecks)) {
const normalizedDecks = knownWordsDecks
.filter((entry): entry is string => typeof entry === 'string') .filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim()) .map((entry) => entry.trim())
.filter((entry) => entry.length > 0); .filter((entry) => entry.length > 0);
if (normalizedDecks.length === nPlusOneDecks.length) { if (normalizedDecks.length === knownWordsDecks.length) {
context.resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)]; context.resolved.ankiConnect.knownWords.decks = [...new Set(normalizedDecks)];
} else if (nPlusOneDecks.length > 0) { } else if (knownWordsDecks.length > 0) {
context.warn( context.warn(
'ankiConnect.nPlusOne.decks', 'ankiConnect.knownWords.decks',
nPlusOneDecks, knownWordsDecks,
context.resolved.ankiConnect.nPlusOne.decks, context.resolved.ankiConnect.knownWords.decks,
'Expected an array of strings.', 'Expected an array of strings.',
); );
context.resolved.ankiConnect.knownWords.decks = DEFAULT_CONFIG.ankiConnect.knownWords.decks;
} else { } else {
context.resolved.ankiConnect.nPlusOne.decks = []; context.resolved.ankiConnect.knownWords.decks = [];
} }
} else if (nPlusOneDecks !== undefined) { } else if (knownWordsDecks !== undefined) {
context.warn( context.warn(
'ankiConnect.nPlusOne.decks', 'ankiConnect.knownWords.decks',
nPlusOneDecks, knownWordsDecks,
context.resolved.ankiConnect.nPlusOne.decks, context.resolved.ankiConnect.knownWords.decks,
'Expected an array of strings.', 'Expected an array of strings.',
); );
context.resolved.ankiConnect.nPlusOne.decks = []; context.resolved.ankiConnect.knownWords.decks = DEFAULT_CONFIG.ankiConnect.knownWords.decks;
} else if (Array.isArray(legacyNPlusOneDecks)) {
const normalizedDecks = legacyNPlusOneDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedDecks.length === legacyNPlusOneDecks.length) {
context.resolved.ankiConnect.knownWords.decks = [...new Set(normalizedDecks)];
context.warn(
'ankiConnect.nPlusOne.decks',
legacyNPlusOneDecks,
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
'Legacy key is deprecated; use ankiConnect.knownWords.decks',
);
} else if (legacyNPlusOneDecks.length > 0) {
context.warn(
'ankiConnect.nPlusOne.decks',
legacyNPlusOneDecks,
context.resolved.ankiConnect.knownWords.decks,
'Expected an array of strings.',
);
context.resolved.ankiConnect.knownWords.decks = DEFAULT_CONFIG.ankiConnect.knownWords.decks;
} else {
context.resolved.ankiConnect.knownWords.decks = [];
context.warn(
'ankiConnect.nPlusOne.decks',
legacyNPlusOneDecks,
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
'Legacy key is deprecated; use ankiConnect.knownWords.decks',
);
}
} else if (legacyNPlusOneDecks !== undefined) {
context.warn(
'ankiConnect.nPlusOne.decks',
legacyNPlusOneDecks,
context.resolved.ankiConnect.knownWords.decks,
'Expected an array of strings.',
);
context.resolved.ankiConnect.knownWords.decks = DEFAULT_CONFIG.ankiConnect.knownWords.decks;
} }
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne); const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
@@ -801,17 +915,34 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne; context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne;
} }
const nPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord); const knownWordsColor = asColor(knownWordsConfig.color);
if (nPlusOneKnownWordColor !== undefined) { const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
context.resolved.ankiConnect.nPlusOne.knownWord = nPlusOneKnownWordColor; if (knownWordsColor !== undefined) {
context.resolved.ankiConnect.knownWords.color = knownWordsColor;
} else if (knownWordsConfig.color !== undefined) {
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.ankiConnect.knownWords.color,
'Expected a hex color value.',
);
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
} else if (legacyNPlusOneKnownWordColor !== undefined) {
context.resolved.ankiConnect.knownWords.color = legacyNPlusOneKnownWordColor;
context.warn(
'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord,
DEFAULT_CONFIG.ankiConnect.knownWords.color,
'Legacy key is deprecated; use ankiConnect.knownWords.color',
);
} else if (nPlusOneConfig.knownWord !== undefined) { } else if (nPlusOneConfig.knownWord !== undefined) {
context.warn( context.warn(
'ankiConnect.nPlusOne.knownWord', 'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord, nPlusOneConfig.knownWord,
context.resolved.ankiConnect.nPlusOne.knownWord, context.resolved.ankiConnect.knownWords.color,
'Expected a hex color value.', 'Expected a hex color value.',
); );
context.resolved.ankiConnect.nPlusOne.knownWord = DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord; context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
} }
if ( if (

View File

@@ -53,6 +53,7 @@ const VOCABULARY_STATS = [
pos2: '自立', pos2: '自立',
pos3: null, pos3: null,
frequency: 100, frequency: 100,
frequencyRank: 42,
firstSeen: Date.now(), firstSeen: Date.now(),
lastSeen: Date.now(), lastSeen: Date.now(),
}, },
@@ -132,9 +133,7 @@ const EPISODES_PER_DAY = [
{ epochDay: Math.floor(Date.now() / 86_400_000), episodeCount: 1 }, { epochDay: Math.floor(Date.now() / 86_400_000), episodeCount: 1 },
]; ];
const NEW_ANIME_PER_DAY = [ const NEW_ANIME_PER_DAY = [{ epochDay: Math.floor(Date.now() / 86_400_000) - 2, newAnimeCount: 2 }];
{ epochDay: Math.floor(Date.now() / 86_400_000) - 2, newAnimeCount: 2 },
];
const WATCH_TIME_PER_ANIME = [ const WATCH_TIME_PER_ANIME = [
{ {
@@ -210,7 +209,12 @@ function createMockTracker(
getSessionSummaries: async () => SESSION_SUMMARIES, getSessionSummaries: async () => SESSION_SUMMARIES,
getDailyRollups: async () => DAILY_ROLLUPS, getDailyRollups: async () => DAILY_ROLLUPS,
getMonthlyRollups: async () => [], getMonthlyRollups: async () => [],
getQueryHints: async () => ({ totalSessions: 5, activeSessions: 1, episodesToday: 2, activeAnimeCount: 3 }), getQueryHints: async () => ({
totalSessions: 5,
activeSessions: 1,
episodesToday: 2,
activeAnimeCount: 3,
}),
getSessionTimeline: async () => [], getSessionTimeline: async () => [],
getSessionEvents: async () => [], getSessionEvents: async () => [],
getVocabularyStats: async () => VOCABULARY_STATS, getVocabularyStats: async () => VOCABULARY_STATS,
@@ -445,7 +449,9 @@ describe('stats server API routes', () => {
}), }),
); );
const res = await app.request('/api/stats/kanji/occurrences?kanji=%E6%97%A5&limit=999999&offset=10'); const res = await app.request(
'/api/stats/kanji/occurrences?kanji=%E6%97%A5&limit=999999&offset=10',
);
assert.equal(res.status, 200); assert.equal(res.status, 200);
const body = await res.json(); const body = await res.json();
assert.ok(Array.isArray(body)); assert.ok(Array.isArray(body));
@@ -711,6 +717,23 @@ describe('stats server API routes', () => {
assert.equal(res.status, 400); assert.equal(res.status, 400);
}); });
it('DELETE /api/stats/sessions/:sessionId deletes a session', async () => {
let deletedSessionId = 0;
const app = createStatsApp(
createMockTracker({
deleteSession: async (sessionId: number) => {
deletedSessionId = sessionId;
},
}),
);
const res = await app.request('/api/stats/sessions/42', { method: 'DELETE' });
assert.equal(res.status, 200);
assert.equal(deletedSessionId, 42);
assert.deepEqual(await res.json(), { ok: true });
});
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => { it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
const app = createStatsApp(createMockTracker()); const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anki/browse', { method: 'POST' }); const res = await app.request('/api/stats/anki/browse', { method: 'POST' });

View File

@@ -130,6 +130,56 @@ test('createFrequencyDictionaryLookup parses composite displayValue by primary r
assert.equal(lookup('高み'), 9933); assert.equal(lookup('高み'), 9933);
}); });
test('createFrequencyDictionaryLookup uses leading display digits for displayValue strings', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
const bankPath = path.join(tempDir, 'term_meta_bank_1.json');
fs.writeFileSync(
bankPath,
JSON.stringify([
['潜む', 1, { frequency: { value: 121, displayValue: '118,121' } }],
['例', 2, { frequency: { value: 1234, displayValue: '1,234' } }],
]),
);
const lookup = await createFrequencyDictionaryLookup({
searchPaths: [tempDir],
log: () => undefined,
});
assert.equal(lookup('潜む'), 118);
assert.equal(lookup('例'), 1);
});
test('createFrequencyDictionaryLookup ignores occurrence-based Yomitan dictionaries', async () => {
const logs: string[] = [];
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
fs.writeFileSync(
path.join(tempDir, 'index.json'),
JSON.stringify({
title: 'CC100',
revision: '1',
frequencyMode: 'occurrence-based',
}),
);
fs.writeFileSync(
path.join(tempDir, 'term_meta_bank_1.json'),
JSON.stringify([['潜む', 1, { frequency: { value: 118121 } }]]),
);
const lookup = await createFrequencyDictionaryLookup({
searchPaths: [tempDir],
log: (message) => {
logs.push(message);
},
});
assert.equal(lookup('潜む'), null);
assert.equal(
logs.some((entry) => entry.includes('occurrence-based') && entry.includes('CC100')),
true,
);
});
test('createFrequencyDictionaryLookup does not require synchronous fs APIs', async () => { test('createFrequencyDictionaryLookup does not require synchronous fs APIs', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
const bankPath = path.join(tempDir, 'term_meta_bank_1.json'); const bankPath = path.join(tempDir, 'term_meta_bank_1.json');

View File

@@ -6,6 +6,8 @@ export interface FrequencyDictionaryLookupOptions {
log: (message: string) => void; log: (message: string) => void;
} }
type FrequencyDictionaryMode = 'occurrence-based' | 'rank-based';
interface FrequencyDictionaryEntry { interface FrequencyDictionaryEntry {
rank: number; rank: number;
term: string; term: string;
@@ -29,30 +31,67 @@ function normalizeFrequencyTerm(value: string): string {
return value.trim().toLowerCase(); return value.trim().toLowerCase();
} }
async function readDictionaryMetadata(
dictionaryPath: string,
log: (message: string) => void,
): Promise<{ title: string | null; frequencyMode: FrequencyDictionaryMode | null }> {
const indexPath = path.join(dictionaryPath, 'index.json');
let rawText: string;
try {
rawText = await fs.readFile(indexPath, 'utf-8');
} catch (error) {
if (isErrorCode(error, 'ENOENT')) {
return { title: null, frequencyMode: null };
}
log(`Failed to read frequency dictionary index ${indexPath}: ${String(error)}`);
return { title: null, frequencyMode: null };
}
let rawIndex: unknown;
try {
rawIndex = JSON.parse(rawText) as unknown;
} catch {
log(`Failed to parse frequency dictionary index as JSON: ${indexPath}`);
return { title: null, frequencyMode: null };
}
if (!rawIndex || typeof rawIndex !== 'object') {
return { title: null, frequencyMode: null };
}
const titleRaw = (rawIndex as { title?: unknown }).title;
const frequencyModeRaw = (rawIndex as { frequencyMode?: unknown }).frequencyMode;
return {
title: typeof titleRaw === 'string' && titleRaw.trim().length > 0 ? titleRaw.trim() : null,
frequencyMode:
frequencyModeRaw === 'occurrence-based' || frequencyModeRaw === 'rank-based'
? frequencyModeRaw
: null,
};
}
function parsePositiveFrequencyString(value: string): number | null { function parsePositiveFrequencyString(value: string): number | null {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) { if (!trimmed) {
return null; return null;
} }
const numericPrefix = trimmed.match(/^\d[\d,]*/)?.[0]; const numericMatch = trimmed.match(/[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/)?.[0];
if (!numericPrefix) { if (!numericMatch) {
return null; return null;
} }
const chunks = numericPrefix.split(','); const parsed = Number.parseFloat(numericMatch);
const normalizedNumber =
chunks.length <= 1
? (chunks[0] ?? '')
: chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk))
? chunks.join('')
: (chunks[0] ?? '');
const parsed = Number.parseInt(normalizedNumber, 10);
if (!Number.isFinite(parsed) || parsed <= 0) { if (!Number.isFinite(parsed) || parsed <= 0) {
return null; return null;
} }
return parsed; const normalized = Math.floor(parsed);
if (!Number.isFinite(normalized) || normalized <= 0) {
return null;
}
return normalized;
} }
function parsePositiveFrequencyNumber(value: unknown): number | null { function parsePositiveFrequencyNumber(value: unknown): number | null {
@@ -68,18 +107,32 @@ function parsePositiveFrequencyNumber(value: unknown): number | null {
return null; return null;
} }
function parseDisplayFrequencyNumber(value: unknown): number | null {
if (typeof value === 'string') {
const leadingDigits = value.trim().match(/^\d+/)?.[0];
if (!leadingDigits) {
return null;
}
const parsed = Number.parseInt(leadingDigits, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return parsePositiveFrequencyNumber(value);
}
function extractFrequencyDisplayValue(meta: unknown): number | null { function extractFrequencyDisplayValue(meta: unknown): number | null {
if (!meta || typeof meta !== 'object') return null; if (!meta || typeof meta !== 'object') return null;
const frequency = (meta as { frequency?: unknown }).frequency; const frequency = (meta as { frequency?: unknown }).frequency;
if (!frequency || typeof frequency !== 'object') return null; if (!frequency || typeof frequency !== 'object') return null;
const rawValue = (frequency as { value?: unknown }).value;
const parsedRawValue = parsePositiveFrequencyNumber(rawValue);
const displayValue = (frequency as { displayValue?: unknown }).displayValue; const displayValue = (frequency as { displayValue?: unknown }).displayValue;
const parsedDisplayValue = parsePositiveFrequencyNumber(displayValue); const parsedDisplayValue = parseDisplayFrequencyNumber(displayValue);
if (parsedDisplayValue !== null) { if (parsedDisplayValue !== null) {
return parsedDisplayValue; return parsedDisplayValue;
} }
const rawValue = (frequency as { value?: unknown }).value; return parsedRawValue;
return parsePositiveFrequencyNumber(rawValue);
} }
function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry | null { function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry | null {
@@ -141,6 +194,15 @@ async function collectDictionaryFromPath(
log: (message: string) => void, log: (message: string) => void,
): Promise<Map<string, number>> { ): Promise<Map<string, number>> {
const terms = new Map<string, number>(); const terms = new Map<string, number>();
const metadata = await readDictionaryMetadata(dictionaryPath, log);
if (metadata.frequencyMode === 'occurrence-based') {
log(
`Skipping occurrence-based frequency dictionary ${
metadata.title ?? dictionaryPath
}; SubMiner frequency tags require rank-based values.`,
);
return terms;
}
let fileNames: string[]; let fileNames: string[];
try { try {

View File

@@ -57,6 +57,8 @@ import {
getWordOccurrences, getWordOccurrences,
getVideoDurationMs, getVideoDurationMs,
markVideoWatched, markVideoWatched,
deleteSession as deleteSessionQuery,
deleteVideo as deleteVideoQuery,
} from './immersion-tracker/query'; } from './immersion-tracker/query';
import { import {
buildVideoKey, buildVideoKey,
@@ -125,6 +127,7 @@ import {
type WordDetailRow, type WordDetailRow,
type WordOccurrenceRow, type WordOccurrenceRow,
type VocabularyStatsRow, type VocabularyStatsRow,
type CountedWordOccurrence,
} from './immersion-tracker/types'; } from './immersion-tracker/types';
import type { MergedToken } from '../../types'; import type { MergedToken } from '../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from './tokenizer/annotation-stage'; import { shouldExcludeTokenFromVocabularyPersistence } from './tokenizer/annotation-stage';
@@ -402,6 +405,70 @@ export class ImmersionTrackerService {
markVideoWatched(this.db, videoId, watched); markVideoWatched(this.db, videoId, watched);
} }
async deleteSession(sessionId: number): Promise<void> {
deleteSessionQuery(this.db, sessionId);
}
async deleteVideo(videoId: number): Promise<void> {
deleteVideoQuery(this.db, videoId);
}
async reassignAnimeAnilist(animeId: number, info: {
anilistId: number;
titleRomaji?: string | null;
titleEnglish?: string | null;
titleNative?: string | null;
episodesTotal?: number | null;
description?: string | null;
coverUrl?: string | null;
}): Promise<void> {
this.db.prepare(`
UPDATE imm_anime
SET anilist_id = ?,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
episodes_total = COALESCE(?, episodes_total),
description = ?,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`).run(
info.anilistId,
info.titleRomaji ?? null,
info.titleEnglish ?? null,
info.titleNative ?? null,
info.episodesTotal ?? null,
info.description ?? null,
Date.now(),
animeId,
);
// Update cover art for all videos in this anime
if (info.coverUrl) {
const videos = this.db.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ video_id: number }>;
let coverBlob: Buffer | null = null;
try {
const res = await fetch(info.coverUrl);
if (res.ok) coverBlob = Buffer.from(await res.arrayBuffer());
} catch { /* ignore */ }
for (const v of videos) {
this.db.prepare(`
INSERT INTO imm_media_art (video_id, anilist_id, cover_url, cover_blob, title_romaji, title_english, episodes_total, fetched_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(video_id) DO UPDATE SET
anilist_id = excluded.anilist_id, cover_url = excluded.cover_url, cover_blob = COALESCE(excluded.cover_blob, cover_blob),
title_romaji = excluded.title_romaji, title_english = excluded.title_english, episodes_total = excluded.episodes_total,
fetched_at_ms = excluded.fetched_at_ms, LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
`).run(
v.video_id, info.anilistId, info.coverUrl, coverBlob,
info.titleRomaji ?? null, info.titleEnglish ?? null, info.episodesTotal ?? null,
Date.now(), Date.now(), Date.now(),
);
}
}
}
async getEpisodeCardEvents(videoId: number): Promise<EpisodeCardEventRow[]> { async getEpisodeCardEvents(videoId: number): Promise<EpisodeCardEventRow[]> {
return getEpisodeCardEvents(this.db, videoId); return getEpisodeCardEvents(this.db, videoId);
} }
@@ -571,19 +638,7 @@ export class ImmersionTrackerService {
this.sessionState.tokensSeen += metrics.tokens; this.sessionState.tokensSeen += metrics.tokens;
this.sessionState.pendingTelemetry = true; this.sessionState.pendingTelemetry = true;
const wordOccurrences = new Map< const wordOccurrences = new Map<string, CountedWordOccurrence>();
string,
{
headword: string;
word: string;
reading: string;
partOfSpeech: string;
pos1: string;
pos2: string;
pos3: string;
occurrenceCount: number;
}
>();
for (const token of tokens ?? []) { for (const token of tokens ?? []) {
if (shouldExcludeTokenFromVocabularyPersistence(token)) { if (shouldExcludeTokenFromVocabularyPersistence(token)) {
continue; continue;
@@ -617,6 +672,7 @@ export class ImmersionTrackerService {
pos2: token.pos2 ?? '', pos2: token.pos2 ?? '',
pos3: token.pos3 ?? '', pos3: token.pos3 ?? '',
occurrenceCount: 1, occurrenceCount: 1,
frequencyRank: token.frequencyRank ?? null,
}); });
} }

View File

@@ -14,6 +14,7 @@ import {
import { startSessionRecord } from '../session.js'; import { startSessionRecord } from '../session.js';
import { import {
cleanupVocabularyStats, cleanupVocabularyStats,
deleteSession,
getAnimeDetail, getAnimeDetail,
getAnimeEpisodes, getAnimeEpisodes,
getAnimeLibrary, getAnimeLibrary,
@@ -295,9 +296,7 @@ test('cleanupVocabularyStats repairs stored POS metadata and removes excluded im
{ headword: '旧', frequency: 1 }, { headword: '旧', frequency: 1 },
], ],
); );
assert.deepEqual( assert.deepEqual(repairedRows, [
repairedRows,
[
{ {
headword: '旧', headword: '旧',
word: '旧', word: '旧',
@@ -322,8 +321,7 @@ test('cleanupVocabularyStats repairs stored POS metadata and removes excluded im
pos1: '動詞', pos1: '動詞',
pos2: '自立', pos2: '自立',
}, },
], ]);
);
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -708,7 +706,7 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
canonicalTitle: 'Frieren', canonicalTitle: 'Frieren',
anilistId: 52_921, anilistId: 52_921,
titleRomaji: 'Sousou no Frieren', titleRomaji: 'Sousou no Frieren',
titleEnglish: 'Frieren: Beyond Journey\'s End', titleEnglish: "Frieren: Beyond Journey's End",
titleNative: '葬送のフリーレン', titleNative: '葬送のフリーレン',
metadataJson: '{"source":"anilist"}', metadataJson: '{"source":"anilist"}',
}); });
@@ -1070,3 +1068,151 @@ test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line c
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
} }
}); });
test('deleteSession removes the session and all associated session-scoped rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/delete-session.mkv', {
canonicalTitle: 'Delete Session Test',
sourcePath: '/tmp/delete-session.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const startedAtMs = 6_000_000;
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
stmts.telemetryInsertStmt.run(
sessionId,
startedAtMs + 1_000,
5_000,
4_000,
3,
9,
9,
1,
2,
1,
0,
0,
0,
0,
0,
startedAtMs + 1_000,
startedAtMs + 1_000,
);
const eventResult = stmts.eventInsertStmt.run(
sessionId,
startedAtMs + 1_500,
EVENT_SUBTITLE_LINE,
0,
0,
900,
2,
0,
'{"line":"delete me"}',
startedAtMs + 1_500,
startedAtMs + 1_500,
);
const eventId = Number(eventResult.lastInsertRowid);
const wordResult = db
.prepare(
`INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run('削除', '削除', 'さくじょ', 'noun', '名詞', '一般', '', startedAtMs, startedAtMs, 1);
const kanjiResult = db
.prepare(
`INSERT INTO imm_kanji (
kanji, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?)`,
)
.run('削', startedAtMs, startedAtMs, 1);
const lineResult = stmts.subtitleLineInsertStmt.run(
sessionId,
eventId,
videoId,
null,
0,
0,
900,
'delete me',
startedAtMs + 1_500,
startedAtMs + 1_500,
);
const lineId = Number(lineResult.lastInsertRowid);
db.prepare(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
VALUES (?, ?, ?)`,
).run(lineId, Number(wordResult.lastInsertRowid), 1);
db.prepare(
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count)
VALUES (?, ?, ?)`,
).run(lineId, Number(kanjiResult.lastInsertRowid), 1);
deleteSession(db, sessionId);
const sessionCount = Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE session_id = ?')
.get(sessionId) as {
total: number;
}
).total,
);
const telemetryCount = Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry WHERE session_id = ?')
.get(sessionId) as { total: number }
).total,
);
const eventCount = Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_session_events WHERE session_id = ?')
.get(sessionId) as {
total: number;
}
).total,
);
const subtitleLineCount = Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE session_id = ?')
.get(sessionId) as { total: number }
).total,
);
const wordOccurrenceCount = Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_word_line_occurrences WHERE line_id = ?')
.get(lineId) as { total: number }
).total,
);
const kanjiOccurrenceCount = Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_kanji_line_occurrences WHERE line_id = ?')
.get(lineId) as { total: number }
).total,
);
assert.equal(sessionCount, 0);
assert.equal(telemetryCount, 0);
assert.equal(eventCount, 0);
assert.equal(subtitleLineCount, 0);
assert.equal(wordOccurrenceCount, 0);
assert.equal(kanjiOccurrenceCount, 0);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});

View File

@@ -223,7 +223,8 @@ export function getVocabularyStats(
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT id AS wordId, headword, word, reading, SELECT id AS wordId, headword, word, reading,
part_of_speech AS partOfSpeech, pos1, pos2, pos3, part_of_speech AS partOfSpeech, pos1, pos2, pos3,
frequency, first_seen AS firstSeen, last_seen AS lastSeen frequency, frequency_rank AS frequencyRank,
first_seen AS firstSeen, last_seen AS lastSeen
FROM imm_words ${whereClause} ORDER BY frequency DESC LIMIT ? FROM imm_words ${whereClause} ORDER BY frequency DESC LIMIT ?
`); `);
const params = hasExclude ? [...excludePos, limit] : [limit]; const params = hasExclude ? [...excludePos, limit] : [limit];
@@ -632,6 +633,7 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
a.title_romaji AS titleRomaji, a.title_romaji AS titleRomaji,
a.title_english AS titleEnglish, a.title_english AS titleEnglish,
a.title_native AS titleNative, a.title_native AS titleNative,
a.description AS description,
COUNT(DISTINCT s.session_id) AS totalSessions, COUNT(DISTINCT s.session_id) AS totalSessions,
COALESCE(SUM(sm.max_active_ms), 0) AS totalActiveMs, COALESCE(SUM(sm.max_active_ms), 0) AS totalActiveMs,
COALESCE(SUM(sm.max_cards), 0) AS totalCards, COALESCE(SUM(sm.max_cards), 0) AS totalCards,
@@ -1165,3 +1167,22 @@ export function isVideoWatched(db: DatabaseSync, videoId: number): boolean {
} | null; } | null;
return row?.watched === 1; return row?.watched === 1;
} }
export function deleteSession(db: DatabaseSync, sessionId: number): void {
db.prepare('DELETE FROM imm_subtitle_lines WHERE session_id = ?').run(sessionId);
db.prepare('DELETE FROM imm_session_telemetry WHERE session_id = ?').run(sessionId);
db.prepare('DELETE FROM imm_session_events WHERE session_id = ?').run(sessionId);
db.prepare('DELETE FROM imm_sessions WHERE session_id = ?').run(sessionId);
}
export function deleteVideo(db: DatabaseSync, videoId: number): void {
const sessions = db.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?').all(videoId) as Array<{ session_id: number }>;
for (const s of sessions) {
deleteSession(db, s.session_id);
}
db.prepare('DELETE FROM imm_subtitle_lines WHERE video_id = ?').run(videoId);
db.prepare('DELETE FROM imm_daily_rollups WHERE video_id = ?').run(videoId);
db.prepare('DELETE FROM imm_monthly_rollups WHERE video_id = ?').run(videoId);
db.prepare('DELETE FROM imm_media_art WHERE video_id = ?').run(videoId);
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
}

View File

@@ -345,6 +345,7 @@ export function ensureSchema(db: DatabaseSync): void {
title_english TEXT, title_english TEXT,
title_native TEXT, title_native TEXT,
episodes_total INTEGER, episodes_total INTEGER,
description TEXT,
metadata_json TEXT, metadata_json TEXT,
CREATED_DATE INTEGER, CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER LAST_UPDATE_DATE INTEGER
@@ -479,6 +480,7 @@ export function ensureSchema(db: DatabaseSync): void {
first_seen REAL, first_seen REAL,
last_seen REAL, last_seen REAL,
frequency INTEGER, frequency INTEGER,
frequency_rank INTEGER,
UNIQUE(headword, word, reading) UNIQUE(headword, word, reading)
); );
`); `);
@@ -672,6 +674,11 @@ export function ensureSchema(db: DatabaseSync): void {
`); `);
} }
if (currentVersion?.schema_version && currentVersion.schema_version < 9) {
addColumnIfMissing(db, 'imm_anime', 'description', 'TEXT');
addColumnIfMissing(db, 'imm_words', 'frequency_rank', 'INTEGER');
}
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_anime_normalized_title CREATE INDEX IF NOT EXISTS idx_anime_normalized_title
ON imm_anime(normalized_title_key) ON imm_anime(normalized_title_key)
@@ -776,9 +783,9 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
`), `),
wordUpsertStmt: db.prepare(` wordUpsertStmt: db.prepare(`
INSERT INTO imm_words ( INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency, frequency_rank
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, 1 ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?
) )
ON CONFLICT(headword, word, reading) DO UPDATE SET ON CONFLICT(headword, word, reading) DO UPDATE SET
frequency = COALESCE(frequency, 0) + 1, frequency = COALESCE(frequency, 0) + 1,
@@ -792,7 +799,12 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2), pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3), pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen), first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen) last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
frequency_rank = CASE
WHEN excluded.frequency_rank IS NOT NULL AND (imm_words.frequency_rank IS NULL OR excluded.frequency_rank < imm_words.frequency_rank)
THEN excluded.frequency_rank
ELSE imm_words.frequency_rank
END
`), `),
kanjiUpsertStmt: db.prepare(` kanjiUpsertStmt: db.prepare(`
INSERT INTO imm_kanji ( INSERT INTO imm_kanji (
@@ -863,6 +875,7 @@ function incrementWordAggregate(
occurrence.pos3, occurrence.pos3,
firstSeen, firstSeen,
lastSeen, lastSeen,
occurrence.frequencyRank ?? null,
); );
} }
const row = stmts.wordIdSelectStmt.get( const row = stmts.wordIdSelectStmt.get(
@@ -926,6 +939,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
write.pos3, write.pos3,
write.firstSeen, write.firstSeen,
write.lastSeen, write.lastSeen,
write.frequencyRank ?? null,
); );
return; return;
} }

View File

@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 7; export const SCHEMA_VERSION = 9;
export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500; export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -128,6 +128,7 @@ interface QueuedWordWrite {
pos3: string; pos3: string;
firstSeen: number; firstSeen: number;
lastSeen: number; lastSeen: number;
frequencyRank: number | null;
} }
interface QueuedKanjiWrite { interface QueuedKanjiWrite {
@@ -146,6 +147,7 @@ export interface CountedWordOccurrence {
pos2: string; pos2: string;
pos3: string; pos3: string;
occurrenceCount: number; occurrenceCount: number;
frequencyRank: number | null;
} }
export interface CountedKanjiOccurrence { export interface CountedKanjiOccurrence {
@@ -240,6 +242,7 @@ export interface VocabularyStatsRow {
pos2: string | null; pos2: string | null;
pos3: string | null; pos3: string | null;
frequency: number; frequency: number;
frequencyRank: number | null;
firstSeen: number; firstSeen: number;
lastSeen: number; lastSeen: number;
} }
@@ -395,6 +398,7 @@ export interface AnimeDetailRow {
titleRomaji: string | null; titleRomaji: string | null;
titleEnglish: string | null; titleEnglish: string | null;
titleNative: string | null; titleNative: string | null;
description: string | null;
totalSessions: number; totalSessions: number;
totalActiveMs: number; totalActiveMs: number;
totalCards: number; totalCards: number;

View File

@@ -18,6 +18,7 @@ export interface StatsServerConfig {
port: number; port: number;
staticDir: string; // Path to stats/dist/ staticDir: string; // Path to stats/dist/
tracker: ImmersionTrackerService; tracker: ImmersionTrackerService;
knownWordCachePath?: string;
} }
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = { const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
@@ -79,7 +80,7 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp
export function createStatsApp( export function createStatsApp(
tracker: ImmersionTrackerService, tracker: ImmersionTrackerService,
options?: { staticDir?: string }, options?: { staticDir?: string; knownWordCachePath?: string },
) { ) {
const app = new Hono(); const app = new Hono();
@@ -259,6 +260,70 @@ export function createStatsApp(
return c.json({ ok: true }); return c.json({ ok: true });
}); });
app.delete('/api/stats/sessions/:sessionId', async (c) => {
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
if (sessionId <= 0) return c.body(null, 400);
await tracker.deleteSession(sessionId);
return c.json({ ok: true });
});
app.delete('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
await tracker.deleteVideo(videoId);
return c.json({ ok: true });
});
app.get('/api/stats/anilist/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]);
try {
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query ($search: String!) {
Page(perPage: 10) {
media(search: $search, type: ANIME) {
id
episodes
season
seasonYear
description(asHtml: false)
coverImage { large medium }
title { romaji english native }
}
}
}`,
variables: { search: query },
}),
});
const json = await res.json() as { data?: { Page?: { media?: unknown[] } } };
return c.json(json.data?.Page?.media ?? []);
} catch {
return c.json([]);
}
});
app.get('/api/stats/known-words', (c) => {
const cachePath = options?.knownWordCachePath;
if (!cachePath || !existsSync(cachePath)) return c.json([]);
try {
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { version?: number; words?: string[] };
if (raw.version === 1 && Array.isArray(raw.words)) return c.json(raw.words);
} catch { /* ignore */ }
return c.json([]);
});
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
if (!body?.anilistId) return c.body(null, 400);
await tracker.reassignAnimeAnilist(animeId, body);
return c.json({ ok: true });
});
app.get('/api/stats/anime/:animeId/cover', async (c) => { app.get('/api/stats/anime/:animeId/cover', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0); const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 404); if (animeId <= 0) return c.body(null, 404);
@@ -363,7 +428,7 @@ export function createStatsApp(
} }
export function startStatsServer(config: StatsServerConfig): { close: () => void } { export function startStatsServer(config: StatsServerConfig): { close: () => void } {
const app = createStatsApp(config.tracker, { staticDir: config.staticDir }); const app = createStatsApp(config.tracker, { staticDir: config.staticDir, knownWordCachePath: config.knownWordCachePath });
const server = serve({ const server = serve({
fetch: app.fetch, fetch: app.fetch,

View File

@@ -55,10 +55,13 @@ export function buildStatsWindowOptions(options: {
}; };
} }
export function buildStatsWindowLoadFileOptions(): { query: Record<string, string> } { export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>;
} {
return { return {
query: { query: {
overlay: '1', overlay: '1',
...(apiBaseUrl ? { apiBase: apiBaseUrl } : {}),
}, },
}; };
} }

View File

@@ -140,3 +140,12 @@ test('buildStatsWindowLoadFileOptions enables overlay rendering mode', () => {
}, },
}); });
}); });
test('buildStatsWindowLoadFileOptions includes provided stats API base URL', () => {
assert.deepEqual(buildStatsWindowLoadFileOptions('http://127.0.0.1:6123'), {
query: {
overlay: '1',
apiBase: 'http://127.0.0.1:6123',
},
});
});

View File

@@ -16,6 +16,8 @@ export interface StatsWindowOptions {
staticDir: string; staticDir: string;
/** Absolute path to the compiled preload-stats.js */ /** Absolute path to the compiled preload-stats.js */
preloadPath: string; preloadPath: string;
/** Resolve the active stats API base URL */
getApiBaseUrl?: () => string;
/** Resolve the active stats toggle key from config */ /** Resolve the active stats toggle key from config */
getToggleKey: () => string; getToggleKey: () => string;
/** Resolve the tracked overlay/mpv bounds */ /** Resolve the tracked overlay/mpv bounds */
@@ -46,7 +48,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
); );
const indexPath = path.join(options.staticDir, 'index.html'); const indexPath = path.join(options.staticDir, 'index.html');
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions()); statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(options.getApiBaseUrl?.()));
statsWindow.on('closed', () => { statsWindow.on('closed', () => {
statsWindow = null; statsWindow = null;

View File

@@ -706,6 +706,240 @@ test('tokenizeSubtitle prefers Yomitan frequency from highest-priority dictionar
assert.equal(result.tokens?.[0]?.frequencyRank, 100); assert.equal(result.tokens?.[0]?.frequencyRank, 100);
}); });
test('tokenizeSubtitle ignores occurrence-based Yomitan frequencies for inflected terms', async () => {
const result = await tokenizeSubtitle(
'潜み',
makeDeps({
getFrequencyDictionaryEnabled: () => true,
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
({
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) {
return [
{
term: '潜む',
reading: 'ひそ',
dictionary: 'CC100',
frequency: 118121,
displayValue: null,
displayValueParsed: false,
},
];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profileIndex: 0,
scanLength: 40,
dictionaries: ['CC100'],
dictionaryPriorityByName: { CC100: 0 },
dictionaryFrequencyModeByName: { CC100: 'occurrence-based' },
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [{ name: 'CC100', enabled: true, id: 0 }],
},
},
],
};
}
return [
{
surface: '潜み',
reading: 'ひそ',
headword: '潜む',
startPos: 0,
endPos: 2,
},
];
},
},
}) as unknown as Electron.BrowserWindow,
}),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
});
test('tokenizeSubtitle falls back to raw term-only Yomitan rank when no scan-derived rank exists', async () => {
const result = await tokenizeSubtitle(
'潜み',
makeDeps({
getFrequencyDictionaryEnabled: () => true,
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
({
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) {
return [
{
term: '潜む',
reading: 'ひそ',
hasReading: false,
dictionary: 'CC100',
frequency: 118121,
displayValue: null,
displayValueParsed: false,
},
];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profileIndex: 0,
scanLength: 40,
dictionaries: ['CC100'],
dictionaryPriorityByName: { CC100: 0 },
dictionaryFrequencyModeByName: { CC100: 'rank-based' },
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [{ name: 'CC100', enabled: true, id: 0 }],
},
},
],
};
}
return [
{
surface: '潜み',
reading: 'ひそ',
headword: '潜む',
startPos: 0,
endPos: 2,
},
];
},
},
}) as unknown as Electron.BrowserWindow,
}),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.frequencyRank, 118121);
});
test('tokenizeSubtitle keeps parsed display rank for term-only inflected headword fallback', async () => {
const result = await tokenizeSubtitle(
'潜み',
makeDeps({
getFrequencyDictionaryEnabled: () => true,
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
({
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) {
return [
{
term: '潜む',
reading: 'ひそ',
hasReading: false,
dictionary: 'CC100',
frequency: 118121,
displayValue: '118,121',
displayValueParsed: false,
},
];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profileIndex: 0,
scanLength: 40,
dictionaries: ['CC100'],
dictionaryPriorityByName: { CC100: 0 },
dictionaryFrequencyModeByName: { CC100: 'rank-based' },
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [{ name: 'CC100', enabled: true, id: 0 }],
},
},
],
};
}
return [
{
surface: '潜み',
reading: 'ひそ',
headword: '潜む',
startPos: 0,
endPos: 2,
},
];
},
},
}) as unknown as Electron.BrowserWindow,
}),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.frequencyRank, 118);
});
test('tokenizeSubtitle preserves scan-derived rank over lower-priority Yomitan fallback', async () => {
const result = await tokenizeSubtitle(
'潜み',
makeDeps({
getFrequencyDictionaryEnabled: () => true,
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
({
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) {
return [
{
term: '潜む',
reading: 'ひそ',
hasReading: false,
dictionary: 'CC100',
dictionaryPriority: 2,
frequency: 118121,
displayValue: null,
displayValueParsed: false,
},
];
}
return [
{
surface: '潜み',
reading: 'ひそむ',
headword: '潜む',
startPos: 0,
endPos: 2,
frequencyRank: 4073,
},
];
},
},
}) as unknown as Electron.BrowserWindow,
}),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.frequencyRank, 4073);
});
test('tokenizeSubtitle uses only selected Yomitan headword for frequency lookup', async () => { test('tokenizeSubtitle uses only selected Yomitan headword for frequency lookup', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'猫です', '猫です',
@@ -836,6 +1070,69 @@ test('tokenizeSubtitle prefers exact headword frequency over surface/reading whe
assert.equal(result.tokens?.[0]?.frequencyRank, 8); assert.equal(result.tokens?.[0]?.frequencyRank, 8);
}); });
test('tokenizeSubtitle falls back to exact surface frequency when merged headword lookup misses', async () => {
const frequencyScripts: string[] = [];
const result = await tokenizeSubtitle(
'陰に',
makeDeps({
getFrequencyDictionaryEnabled: () => true,
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
({
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) {
frequencyScripts.push(script);
return script.includes('"term":"陰に","reading":"いんに"')
? [
{
term: '陰に',
reading: 'いんに',
dictionary: 'freq-dict',
frequency: 5702,
displayValue: '5702',
displayValueParsed: true,
},
]
: [];
}
return [
{
source: 'scanning-parser',
index: 0,
content: [
[
{
text: '陰に',
reading: 'いんに',
headwords: [[{ term: '陰' }]],
},
],
],
},
];
},
},
}) as unknown as Electron.BrowserWindow,
}),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.surface, '陰に');
assert.equal(result.tokens?.[0]?.headword, '陰');
assert.equal(result.tokens?.[0]?.frequencyRank, 5702);
assert.equal(
frequencyScripts.some((script) => script.includes('"term":"陰","reading":"いんに"')),
true,
);
assert.equal(
frequencyScripts.some((script) => script.includes('"term":"陰に","reading":"いんに"')),
true,
);
});
test('tokenizeSubtitle keeps no frequency when only reading matches and headword misses', async () => { test('tokenizeSubtitle keeps no frequency when only reading matches and headword misses', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'猫です', '猫です',
@@ -2287,6 +2584,131 @@ test('tokenizeSubtitle keeps correct MeCab pos1 enrichment when Yomitan offsets
assert.equal(targets[0]?.surface, '仮面'); assert.equal(targets[0]?.surface, '仮面');
}); });
test('tokenizeSubtitle preserves merged token frequency when MeCab positions cross a newline gap', async () => {
const parserWindow = {
isDestroyed: () => false,
webContents: {
executeJavaScript: async (script: string) => {
if (script.includes('getTermFrequencies')) {
return script.includes('"term":"陰に","reading":"いんに"')
? [
{
term: '陰に',
reading: 'いんに',
dictionary: 'JPDBv2㋕',
frequency: 5702,
displayValue: '5702',
displayValueParsed: false,
},
]
: [];
}
return [
{
surface: 'X',
reading: 'えっくす',
headword: 'X',
startPos: 0,
endPos: 1,
},
{
surface: '陰に',
reading: 'いんに',
headword: '陰に',
startPos: 2,
endPos: 4,
},
{
surface: '潜み',
reading: 'ひそ',
headword: '潜む',
startPos: 4,
endPos: 6,
},
];
},
},
} as unknown as Electron.BrowserWindow;
const deps = createTokenizerDepsRuntime({
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () => parserWindow,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
getKnownWordMatchMode: () => 'headword',
getJlptLevel: () => null,
getFrequencyDictionaryEnabled: () => true,
getMecabTokenizer: () => ({
tokenize: async () => [
{
word: 'X',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '一般',
pos3: '',
pos4: '',
inflectionType: '',
inflectionForm: '',
headword: 'X',
katakanaReading: 'エックス',
pronunciation: 'エックス',
},
{
word: '陰',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '一般',
pos3: '',
pos4: '',
inflectionType: '',
inflectionForm: '',
headword: '陰',
katakanaReading: 'カゲ',
pronunciation: 'カゲ',
},
{
word: 'に',
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '格助詞',
pos3: '一般',
pos4: '',
inflectionType: '',
inflectionForm: '',
headword: 'に',
katakanaReading: 'ニ',
pronunciation: 'ニ',
},
{
word: '潜み',
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
pos3: '',
pos4: '',
inflectionType: '五段・マ行',
inflectionForm: '連用形',
headword: '潜む',
katakanaReading: 'ヒソミ',
pronunciation: 'ヒソミ',
},
],
}),
});
const result = await tokenizeSubtitle('X\n陰に潜み', deps);
assert.equal(result.tokens?.[1]?.surface, '陰に');
assert.equal(result.tokens?.[1]?.pos1, '名詞|助詞');
assert.equal(result.tokens?.[1]?.pos2, '一般|格助詞');
assert.equal(result.tokens?.[1]?.frequencyRank, 5702);
});
test('tokenizeSubtitle does not color 1-2 word sentences by default', async () => { test('tokenizeSubtitle does not color 1-2 word sentences by default', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'猫です', '猫です',

View File

@@ -23,6 +23,7 @@ import {
requestYomitanScanTokens, requestYomitanScanTokens,
requestYomitanTermFrequencies, requestYomitanTermFrequencies,
} from './tokenizer/yomitan-parser-runtime'; } from './tokenizer/yomitan-parser-runtime';
import type { YomitanTermFrequency } from './tokenizer/yomitan-parser-runtime';
const logger = createLogger('main:tokenizer'); const logger = createLogger('main:tokenizer');
@@ -225,7 +226,13 @@ export function createTokenizerDepsRuntime(
return null; return null;
} }
return mergeTokens(rawTokens, options.isKnownWord, options.getKnownWordMatchMode(), false); return mergeTokens(
rawTokens,
options.isKnownWord,
options.getKnownWordMatchMode(),
false,
text,
);
}, },
enrichTokensWithMecab: async (tokens, mecabTokens) => enrichTokensWithMecab: async (tokens, mecabTokens) =>
enrichTokensWithMecabAsync(tokens, mecabTokens), enrichTokensWithMecabAsync(tokens, mecabTokens),
@@ -336,56 +343,154 @@ function resolveFrequencyLookupText(
return token.surface; return token.surface;
} }
function resolveYomitanFrequencyLookupTexts(
token: MergedToken,
matchMode: FrequencyDictionaryMatchMode,
): string[] {
const primaryLookupText = resolveFrequencyLookupText(token, matchMode).trim();
if (!primaryLookupText) {
return [];
}
if (matchMode !== 'headword') {
return [primaryLookupText];
}
const normalizedHeadword = token.headword.trim();
const normalizedSurface = token.surface.trim();
if (
!normalizedHeadword ||
!normalizedSurface ||
normalizedSurface === normalizedHeadword ||
normalizedSurface === primaryLookupText
) {
return [primaryLookupText];
}
return [primaryLookupText, normalizedSurface];
}
function buildYomitanFrequencyTermReadingList( function buildYomitanFrequencyTermReadingList(
tokens: MergedToken[], tokens: MergedToken[],
matchMode: FrequencyDictionaryMatchMode, matchMode: FrequencyDictionaryMatchMode,
): Array<{ term: string; reading: string | null }> { ): Array<{ term: string; reading: string | null }> {
const termReadingList: Array<{ term: string; reading: string | null }> = []; const termReadingList: Array<{ term: string; reading: string | null }> = [];
for (const token of tokens) { for (const token of tokens) {
const term = resolveFrequencyLookupText(token, matchMode).trim();
if (!term) {
continue;
}
const readingRaw = const readingRaw =
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null; token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
for (const term of resolveYomitanFrequencyLookupTexts(token, matchMode)) {
termReadingList.push({ term, reading: readingRaw }); termReadingList.push({ term, reading: readingRaw });
} }
}
return termReadingList; return termReadingList;
} }
function buildYomitanFrequencyRankMap( function makeYomitanFrequencyPairKey(term: string, reading: string | null): string {
frequencies: ReadonlyArray<{ term: string; frequency: number; dictionaryPriority?: number }>, return `${term}\u0000${reading ?? ''}`;
): Map<string, number> { }
const rankByTerm = new Map<string, { rank: number; dictionaryPriority: number }>();
interface NormalizedYomitanTermFrequency extends YomitanTermFrequency {
reading: string | null;
frequency: number;
}
interface YomitanFrequencyIndex {
byPair: Map<string, NormalizedYomitanTermFrequency[]>;
byTerm: Map<string, NormalizedYomitanTermFrequency[]>;
}
function appendYomitanFrequencyEntry(
map: Map<string, NormalizedYomitanTermFrequency[]>,
key: string,
entry: NormalizedYomitanTermFrequency,
): void {
const existing = map.get(key);
if (existing) {
existing.push(entry);
return;
}
map.set(key, [entry]);
}
function buildYomitanFrequencyIndex(
frequencies: ReadonlyArray<YomitanTermFrequency>,
): YomitanFrequencyIndex {
const byPair = new Map<string, NormalizedYomitanTermFrequency[]>();
const byTerm = new Map<string, NormalizedYomitanTermFrequency[]>();
for (const frequency of frequencies) { for (const frequency of frequencies) {
const normalizedTerm = frequency.term.trim(); const term = frequency.term.trim();
const rank = normalizePositiveFrequencyRank(frequency.frequency); const rank = normalizePositiveFrequencyRank(frequency.frequency);
if (!normalizedTerm || rank === null) { if (!term || rank === null) {
continue; continue;
} }
const dictionaryPriority =
typeof frequency.dictionaryPriority === 'number' && const reading =
Number.isFinite(frequency.dictionaryPriority) typeof frequency.reading === 'string' && frequency.reading.trim().length > 0
? Math.max(0, Math.floor(frequency.dictionaryPriority)) ? frequency.reading.trim()
: Number.MAX_SAFE_INTEGER; : null;
const current = rankByTerm.get(normalizedTerm); const normalizedEntry: NormalizedYomitanTermFrequency = {
...frequency,
term,
reading,
frequency: rank,
};
appendYomitanFrequencyEntry(byPair, makeYomitanFrequencyPairKey(term, reading), normalizedEntry);
appendYomitanFrequencyEntry(byTerm, term, normalizedEntry);
}
return { byPair, byTerm };
}
function selectBestYomitanFrequencyRank(
entries: ReadonlyArray<NormalizedYomitanTermFrequency>,
): number | null {
let bestEntry: NormalizedYomitanTermFrequency | null = null;
for (const entry of entries) {
if ( if (
current === undefined || bestEntry === null ||
dictionaryPriority < current.dictionaryPriority || entry.dictionaryPriority < bestEntry.dictionaryPriority ||
(dictionaryPriority === current.dictionaryPriority && rank < current.rank) (entry.dictionaryPriority === bestEntry.dictionaryPriority &&
entry.frequency < bestEntry.frequency)
) { ) {
rankByTerm.set(normalizedTerm, { rank, dictionaryPriority }); bestEntry = entry;
} }
} }
const collapsedRankByTerm = new Map<string, number>(); return bestEntry?.frequency ?? null;
for (const [term, entry] of rankByTerm.entries()) { }
collapsedRankByTerm.set(term, entry.rank);
function getYomitanFrequencyRank(
token: MergedToken,
candidateText: string,
matchMode: FrequencyDictionaryMatchMode,
frequencyIndex: YomitanFrequencyIndex,
): number | null {
const normalizedCandidateText = candidateText.trim();
if (!normalizedCandidateText) {
return null;
} }
return collapsedRankByTerm; const reading =
typeof token.reading === 'string' && token.reading.trim().length > 0 ? token.reading.trim() : null;
const pairEntries =
frequencyIndex.byPair.get(makeYomitanFrequencyPairKey(normalizedCandidateText, reading)) ?? [];
const candidateEntries =
pairEntries.length > 0 ? pairEntries : (frequencyIndex.byTerm.get(normalizedCandidateText) ?? []);
if (candidateEntries.length === 0) {
return null;
}
const normalizedHeadword = token.headword.trim();
const normalizedSurface = token.surface.trim();
const isInflectedHeadwordFallback =
matchMode === 'headword' &&
normalizedCandidateText === normalizedHeadword &&
normalizedSurface.length > 0 &&
normalizedSurface !== normalizedHeadword;
return selectBestYomitanFrequencyRank(candidateEntries);
} }
function getLocalFrequencyRank( function getLocalFrequencyRank(
@@ -416,7 +521,7 @@ function getLocalFrequencyRank(
function applyFrequencyRanks( function applyFrequencyRanks(
tokens: MergedToken[], tokens: MergedToken[],
matchMode: FrequencyDictionaryMatchMode, matchMode: FrequencyDictionaryMatchMode,
yomitanRankByTerm: Map<string, number>, yomitanFrequencyIndex: YomitanFrequencyIndex,
getFrequencyRank: FrequencyDictionaryLookup | undefined, getFrequencyRank: FrequencyDictionaryLookup | undefined,
): MergedToken[] { ): MergedToken[] {
if (tokens.length === 0) { if (tokens.length === 0) {
@@ -441,13 +546,20 @@ function applyFrequencyRanks(
}; };
} }
const yomitanRank = yomitanRankByTerm.get(lookupText); for (const candidateText of resolveYomitanFrequencyLookupTexts(token, matchMode)) {
if (yomitanRank !== undefined) { const yomitanRank = getYomitanFrequencyRank(
token,
candidateText,
matchMode,
yomitanFrequencyIndex,
);
if (yomitanRank !== null) {
return { return {
...token, ...token,
frequencyRank: yomitanRank, frequencyRank: yomitanRank,
}; };
} }
}
if (!getFrequencyRank) { if (!getFrequencyRank) {
return { return {
@@ -501,6 +613,7 @@ async function parseWithYomitanInternalParser(
isKnown: false, isKnown: false,
isNPlusOneTarget: false, isNPlusOneTarget: false,
isNameMatch: token.isNameMatch ?? false, isNameMatch: token.isNameMatch ?? false,
frequencyRank: token.frequencyRank,
}), }),
), ),
); );
@@ -510,7 +623,7 @@ async function parseWithYomitanInternalParser(
} }
deps.onTokenizationReady?.(text); deps.onTokenizationReady?.(text);
const frequencyRankPromise: Promise<Map<string, number>> = options.frequencyEnabled const frequencyRankPromise: Promise<YomitanFrequencyIndex> = options.frequencyEnabled
? (async () => { ? (async () => {
const frequencyMatchMode = options.frequencyMatchMode; const frequencyMatchMode = options.frequencyMatchMode;
const termReadingList = buildYomitanFrequencyTermReadingList( const termReadingList = buildYomitanFrequencyTermReadingList(
@@ -522,9 +635,9 @@ async function parseWithYomitanInternalParser(
deps, deps,
logger, logger,
); );
return buildYomitanFrequencyRankMap(yomitanFrequencies); return buildYomitanFrequencyIndex(yomitanFrequencies);
})() })()
: Promise.resolve(new Map<string, number>()); : Promise.resolve({ byPair: new Map(), byTerm: new Map() });
const mecabEnrichmentPromise: Promise<MergedToken[]> = needsMecabPosEnrichment(options) const mecabEnrichmentPromise: Promise<MergedToken[]> = needsMecabPosEnrichment(options)
? (async () => { ? (async () => {
@@ -545,7 +658,7 @@ async function parseWithYomitanInternalParser(
})() })()
: Promise.resolve(normalizedSelectedTokens); : Promise.resolve(normalizedSelectedTokens);
const [yomitanRankByTerm, enrichedTokens] = await Promise.all([ const [yomitanFrequencyIndex, enrichedTokens] = await Promise.all([
frequencyRankPromise, frequencyRankPromise,
mecabEnrichmentPromise, mecabEnrichmentPromise,
]); ]);
@@ -554,7 +667,7 @@ async function parseWithYomitanInternalParser(
return applyFrequencyRanks( return applyFrequencyRanks(
enrichedTokens, enrichedTokens,
options.frequencyMatchMode, options.frequencyMatchMode,
yomitanRankByTerm, yomitanFrequencyIndex,
deps.getFrequencyRank, deps.getFrequencyRank,
); );
} }

View File

@@ -293,6 +293,29 @@ test('annotateTokens excludes default non-independent pos2 from frequency and N+
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
}); });
test('annotateTokens keeps frequency for kanji noun tokens even when mecab marks them non-independent', () => {
const tokens = [
makeToken({
surface: '者',
reading: 'もの',
headword: '者',
partOfSpeech: PartOfSpeech.other,
pos1: '名詞',
pos2: '非自立',
pos3: '一般',
startPos: 0,
endPos: 1,
frequencyRank: 475,
}),
];
const result = annotateTokens(tokens, makeDeps(), {
minSentenceWordsForNPlusOne: 1,
});
assert.equal(result[0]?.frequencyRank, 475);
});
test('annotateTokens excludes likely kana SFX tokens from frequency when POS tags are missing', () => { test('annotateTokens excludes likely kana SFX tokens from frequency when POS tags are missing', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({

View File

@@ -89,6 +89,23 @@ function normalizePos2Tag(pos2: string | undefined): string {
return typeof pos2 === 'string' ? pos2.trim() : ''; return typeof pos2 === 'string' ? pos2.trim() : '';
} }
function hasKanjiChar(text: string): boolean {
for (const char of text) {
const code = char.codePointAt(0);
if (code === undefined) {
continue;
}
if (
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0xf900 && code <= 0xfaff)
) {
return true;
}
}
return false;
}
function isExcludedComponent( function isExcludedComponent(
pos1: string | undefined, pos1: string | undefined,
pos2: string | undefined, pos2: string | undefined,
@@ -169,6 +186,34 @@ function isFrequencyExcludedByPos(
); );
} }
function shouldKeepFrequencyForNonIndependentKanjiNoun(
token: MergedToken,
pos1Exclusions: ReadonlySet<string>,
): boolean {
if (pos1Exclusions.has('名詞')) {
return false;
}
const rank =
typeof token.frequencyRank === 'number' && Number.isFinite(token.frequencyRank)
? Math.max(1, Math.floor(token.frequencyRank))
: null;
if (rank === null) {
return false;
}
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
const pos2Parts = splitNormalizedTagParts(normalizePos2Tag(token.pos2));
if (pos1Parts.length !== 1 || pos2Parts.length !== 1) {
return false;
}
if (pos1Parts[0] !== '名詞' || pos2Parts[0] !== '非自立') {
return false;
}
return hasKanjiChar(token.surface) || hasKanjiChar(token.headword);
}
export function shouldExcludeTokenFromVocabularyPersistence( export function shouldExcludeTokenFromVocabularyPersistence(
token: MergedToken, token: MergedToken,
options: Pick<AnnotationStageOptions, 'pos1Exclusions' | 'pos2Exclusions'> = {}, options: Pick<AnnotationStageOptions, 'pos1Exclusions' | 'pos2Exclusions'> = {},
@@ -454,7 +499,10 @@ function filterTokenFrequencyRank(
pos1Exclusions: ReadonlySet<string>, pos1Exclusions: ReadonlySet<string>,
pos2Exclusions: ReadonlySet<string>, pos2Exclusions: ReadonlySet<string>,
): number | undefined { ): number | undefined {
if (isFrequencyExcludedByPos(token, pos1Exclusions, pos2Exclusions)) { if (
isFrequencyExcludedByPos(token, pos1Exclusions, pos2Exclusions) &&
!shouldKeepFrequencyForNonIndependentKanjiNoun(token, pos1Exclusions)
) {
return undefined; return undefined;
} }

View File

@@ -188,6 +188,7 @@ test('requestYomitanTermFrequencies returns normalized frequency entries', async
{ {
term: '猫', term: '猫',
reading: 'ねこ', reading: 'ねこ',
hasReading: true,
dictionary: 'freq-dict', dictionary: 'freq-dict',
dictionaryPriority: 0, dictionaryPriority: 0,
frequency: 77, frequency: 77,
@@ -197,6 +198,7 @@ test('requestYomitanTermFrequencies returns normalized frequency entries', async
{ {
term: '鍛える', term: '鍛える',
reading: 'きたえる', reading: 'きたえる',
hasReading: false,
dictionary: 'freq-dict', dictionary: 'freq-dict',
dictionaryPriority: 1, dictionaryPriority: 1,
frequency: 46961, frequency: 46961,
@@ -217,9 +219,11 @@ test('requestYomitanTermFrequencies returns normalized frequency entries', async
assert.equal(result.length, 2); assert.equal(result.length, 2);
assert.equal(result[0]?.term, '猫'); assert.equal(result[0]?.term, '猫');
assert.equal(result[0]?.hasReading, true);
assert.equal(result[0]?.frequency, 77); assert.equal(result[0]?.frequency, 77);
assert.equal(result[0]?.dictionaryPriority, 0); assert.equal(result[0]?.dictionaryPriority, 0);
assert.equal(result[1]?.term, '鍛える'); assert.equal(result[1]?.term, '鍛える');
assert.equal(result[1]?.hasReading, false);
assert.equal(result[1]?.frequency, 2847); assert.equal(result[1]?.frequency, 2847);
assert.match(scriptValue, /getTermFrequencies/); assert.match(scriptValue, /getTermFrequencies/);
assert.match(scriptValue, /optionsGetFull/); assert.match(scriptValue, /optionsGetFull/);
@@ -247,6 +251,96 @@ test('requestYomitanTermFrequencies prefers primary rank from displayValue array
assert.equal(result[0]?.frequency, 7141); assert.equal(result[0]?.frequency, 7141);
}); });
test('requestYomitanTermFrequencies prefers primary rank from displayValue string pair when raw frequency matches trailing count', async () => {
const deps = createDeps(async () => [
{
term: '潜む',
reading: 'ひそむ',
dictionary: 'freq-dict',
dictionaryPriority: 0,
frequency: 121,
displayValue: '118,121',
displayValueParsed: false,
},
]);
const result = await requestYomitanTermFrequencies([{ term: '潜む', reading: 'ひそむ' }], deps, {
error: () => undefined,
});
assert.equal(result.length, 1);
assert.equal(result[0]?.term, '潜む');
assert.equal(result[0]?.frequency, 118);
});
test('requestYomitanTermFrequencies uses leading display digits for displayValue strings', async () => {
const deps = createDeps(async () => [
{
term: '例',
reading: 'れい',
dictionary: 'freq-dict',
dictionaryPriority: 0,
frequency: 1234,
displayValue: '1,234',
displayValueParsed: false,
},
]);
const result = await requestYomitanTermFrequencies([{ term: '例', reading: 'れい' }], deps, {
error: () => undefined,
});
assert.equal(result.length, 1);
assert.equal(result[0]?.term, '例');
assert.equal(result[0]?.frequency, 1);
});
test('requestYomitanTermFrequencies ignores occurrence-based dictionaries for rank tagging', async () => {
let metadataScript = '';
const deps = createDeps(async (script) => {
if (script.includes('getTermFrequencies')) {
return [
{
term: '潜む',
reading: 'ひそむ',
dictionary: 'CC100',
frequency: 118121,
displayValue: null,
displayValueParsed: false,
},
];
}
if (script.includes('optionsGetFull')) {
metadataScript = script;
return {
profileCurrent: 0,
profileIndex: 0,
scanLength: 40,
dictionaries: ['CC100'],
dictionaryPriorityByName: { CC100: 0 },
dictionaryFrequencyModeByName: { CC100: 'occurrence-based' },
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [{ name: 'CC100', enabled: true, id: 0 }],
},
},
],
};
}
return [];
});
const result = await requestYomitanTermFrequencies([{ term: '潜む', reading: 'ひそむ' }], deps, {
error: () => undefined,
});
assert.deepEqual(result, []);
assert.match(metadataScript, /getDictionaryInfo/);
});
test('requestYomitanTermFrequencies requests term-only fallback only after reading miss', async () => { test('requestYomitanTermFrequencies requests term-only fallback only after reading miss', async () => {
const frequencyScripts: string[] = []; const frequencyScripts: string[] = [];
const deps = createDeps(async (script) => { const deps = createDeps(async (script) => {
@@ -485,6 +579,317 @@ test('requestYomitanScanTokens uses left-to-right termsFind scanning instead of
assert.match(scannerScript ?? '', /deinflect:\s*true/); assert.match(scannerScript ?? '', /deinflect:\s*true/);
}); });
test('requestYomitanScanTokens extracts best frequency rank from selected termsFind entry', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
if (script.includes('termsFind')) {
scannerScript = script;
return [];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profileIndex: 0,
scanLength: 40,
dictionaries: ['JPDBv2㋕', 'Jiten', 'CC100'],
dictionaryPriorityByName: {
'JPDBv2㋕': 0,
Jiten: 1,
CC100: 2,
},
dictionaryFrequencyModeByName: {
'JPDBv2㋕': 'rank-based',
Jiten: 'rank-based',
CC100: 'rank-based',
},
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [
{ name: 'JPDBv2㋕', enabled: true, id: 0 },
{ name: 'Jiten', enabled: true, id: 1 },
{ name: 'CC100', enabled: true, id: 2 },
],
},
},
],
};
}
return null;
});
await requestYomitanScanTokens('潜み', deps, {
error: () => undefined,
});
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text ?? '';
if (!text.startsWith('潜み')) {
return { originalTextLength: 0, dictionaryEntries: [] };
}
return {
originalTextLength: 2,
dictionaryEntries: [
{
headwords: [
{
term: '潜む',
reading: 'ひそむ',
sources: [{ originalText: '潜み', isPrimary: true, matchType: 'exact' }],
},
],
frequencies: [
{
headwordIndex: 0,
dictionary: 'JPDBv2㋕',
frequency: 20181,
displayValue: '4073,20181句',
},
{
headwordIndex: 0,
dictionary: 'Jiten',
frequency: 28594,
displayValue: '4592,28594句',
},
{
headwordIndex: 0,
dictionary: 'CC100',
frequency: 118121,
displayValue: null,
},
],
},
],
};
});
assert.deepEqual(result, [
{
surface: '潜み',
reading: 'ひそ',
headword: '潜む',
startPos: 0,
endPos: 2,
isNameMatch: false,
frequencyRank: 4073,
},
]);
});
test('requestYomitanScanTokens uses frequency from later exact-match entry when first exact entry has none', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
if (script.includes('termsFind')) {
scannerScript = script;
return [];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profileIndex: 0,
scanLength: 40,
dictionaries: ['JPDBv2㋕', 'Jiten', 'CC100'],
dictionaryPriorityByName: {
'JPDBv2㋕': 0,
Jiten: 1,
CC100: 2,
},
dictionaryFrequencyModeByName: {
'JPDBv2㋕': 'rank-based',
Jiten: 'rank-based',
CC100: 'rank-based',
},
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [
{ name: 'JPDBv2㋕', enabled: true, id: 0 },
{ name: 'Jiten', enabled: true, id: 1 },
{ name: 'CC100', enabled: true, id: 2 },
],
},
},
],
};
}
return null;
});
await requestYomitanScanTokens('者', deps, {
error: () => undefined,
});
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text ?? '';
if (!text.startsWith('者')) {
return { originalTextLength: 0, dictionaryEntries: [] };
}
return {
originalTextLength: 1,
dictionaryEntries: [
{
headwords: [
{
term: '者',
reading: 'もの',
sources: [{ originalText: '者', isPrimary: true, matchType: 'exact' }],
},
],
frequencies: [],
},
{
headwords: [
{
term: '者',
reading: 'もの',
sources: [{ originalText: '者', isPrimary: true, matchType: 'exact' }],
},
],
frequencies: [
{
headwordIndex: 0,
dictionary: 'JPDBv2㋕',
frequency: 79601,
displayValue: '475,79601句',
},
{
headwordIndex: 0,
dictionary: 'Jiten',
frequency: 338,
displayValue: '338',
},
],
},
],
};
});
assert.deepEqual(result, [
{
surface: '者',
reading: 'もの',
headword: '者',
startPos: 0,
endPos: 1,
isNameMatch: false,
frequencyRank: 475,
},
]);
});
test('requestYomitanScanTokens can use frequency from later exact secondary-match entry', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
if (script.includes('termsFind')) {
scannerScript = script;
return [];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profileIndex: 0,
scanLength: 40,
dictionaries: ['JPDBv2㋕', 'Jiten', 'CC100'],
dictionaryPriorityByName: {
'JPDBv2㋕': 0,
Jiten: 1,
CC100: 2,
},
dictionaryFrequencyModeByName: {
'JPDBv2㋕': 'rank-based',
Jiten: 'rank-based',
CC100: 'rank-based',
},
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [
{ name: 'JPDBv2㋕', enabled: true, id: 0 },
{ name: 'Jiten', enabled: true, id: 1 },
{ name: 'CC100', enabled: true, id: 2 },
],
},
},
],
};
}
return null;
});
await requestYomitanScanTokens('者', deps, {
error: () => undefined,
});
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text ?? '';
if (!text.startsWith('者')) {
return { originalTextLength: 0, dictionaryEntries: [] };
}
return {
originalTextLength: 1,
dictionaryEntries: [
{
headwords: [
{
term: '者',
reading: 'もの',
sources: [{ originalText: '者', isPrimary: true, matchType: 'exact' }],
},
],
frequencies: [],
},
{
headwords: [
{
term: '者',
reading: 'もの',
sources: [{ originalText: '者', isPrimary: false, matchType: 'exact' }],
},
],
frequencies: [
{
headwordIndex: 0,
dictionary: 'JPDBv2㋕',
frequency: 79601,
displayValue: '475,79601句',
},
],
},
],
};
});
assert.deepEqual(result, [
{
surface: '者',
reading: 'もの',
headword: '者',
startPos: 0,
endPos: 1,
isNameMatch: false,
frequencyRank: 475,
},
]);
});
test('requestYomitanScanTokens marks tokens backed by SubMiner character dictionary entries', async () => { test('requestYomitanScanTokens marks tokens backed by SubMiner character dictionary entries', async () => {
const deps = createDeps(async (script) => { const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) { if (script.includes('optionsGetFull')) {

View File

@@ -20,19 +20,24 @@ interface YomitanParserRuntimeDeps {
createYomitanExtensionWindow?: (pageName: string) => Promise<BrowserWindow | null>; createYomitanExtensionWindow?: (pageName: string) => Promise<BrowserWindow | null>;
} }
type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
export interface YomitanDictionaryInfo { export interface YomitanDictionaryInfo {
title: string; title: string;
revision?: string | number; revision?: string | number;
frequencyMode?: YomitanFrequencyMode;
} }
export interface YomitanTermFrequency { export interface YomitanTermFrequency {
term: string; term: string;
reading: string | null; reading: string | null;
hasReading: boolean;
dictionary: string; dictionary: string;
dictionaryPriority: number; dictionaryPriority: number;
frequency: number; frequency: number;
displayValue: string | null; displayValue: string | null;
displayValueParsed: boolean; displayValueParsed: boolean;
frequencyDerivedFromDisplayValue: boolean;
} }
export interface YomitanTermReadingPair { export interface YomitanTermReadingPair {
@@ -47,6 +52,7 @@ export interface YomitanScanToken {
startPos: number; startPos: number;
endPos: number; endPos: number;
isNameMatch?: boolean; isNameMatch?: boolean;
frequencyRank?: number;
} }
interface YomitanProfileMetadata { interface YomitanProfileMetadata {
@@ -54,6 +60,7 @@ interface YomitanProfileMetadata {
scanLength: number; scanLength: number;
dictionaries: string[]; dictionaries: string[];
dictionaryPriorityByName: Record<string, number>; dictionaryPriorityByName: Record<string, number>;
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>;
} }
const DEFAULT_YOMITAN_SCAN_LENGTH = 40; const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
@@ -78,7 +85,8 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
typeof entry.headword === 'string' && typeof entry.headword === 'string' &&
typeof entry.startPos === 'number' && typeof entry.startPos === 'number' &&
typeof entry.endPos === 'number' && typeof entry.endPos === 'number' &&
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean'), (entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number'),
) )
); );
} }
@@ -117,24 +125,22 @@ function parsePositiveFrequencyString(value: string): number | null {
return null; return null;
} }
const numericPrefix = trimmed.match(/^\d[\d,]*/)?.[0]; const numericMatch = trimmed.match(/[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/)?.[0];
if (!numericPrefix) { if (!numericMatch) {
return null; return null;
} }
const chunks = numericPrefix.split(','); const parsed = Number.parseFloat(numericMatch);
const normalizedNumber =
chunks.length <= 1
? (chunks[0] ?? '')
: chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk))
? chunks.join('')
: (chunks[0] ?? '');
const parsed = Number.parseInt(normalizedNumber, 10);
if (!Number.isFinite(parsed) || parsed <= 0) { if (!Number.isFinite(parsed) || parsed <= 0) {
return null; return null;
} }
return parsed; const normalized = Math.floor(parsed);
if (!Number.isFinite(normalized) || normalized <= 0) {
return null;
}
return normalized;
} }
function parsePositiveFrequencyValue(value: unknown): number | null { function parsePositiveFrequencyValue(value: unknown): number | null {
@@ -159,6 +165,19 @@ function parsePositiveFrequencyValue(value: unknown): number | null {
return null; return null;
} }
function parseDisplayFrequencyValue(value: unknown): number | null {
if (typeof value === 'string') {
const leadingDigits = value.trim().match(/^\d+/)?.[0];
if (!leadingDigits) {
return null;
}
const parsed = Number.parseInt(leadingDigits, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return parsePositiveFrequencyValue(value);
}
function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null { function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
if (!isObject(value)) { if (!isObject(value)) {
return null; return null;
@@ -169,9 +188,7 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
const rawFrequency = parsePositiveFrequencyValue(value.frequency); const rawFrequency = parsePositiveFrequencyValue(value.frequency);
const displayValueRaw = value.displayValue; const displayValueRaw = value.displayValue;
const parsedDisplayFrequency = const parsedDisplayFrequency =
displayValueRaw !== null && displayValueRaw !== undefined displayValueRaw !== null && displayValueRaw !== undefined ? parseDisplayFrequencyValue(displayValueRaw) : null;
? parsePositiveFrequencyValue(displayValueRaw)
: null;
const frequency = parsedDisplayFrequency ?? rawFrequency; const frequency = parsedDisplayFrequency ?? rawFrequency;
if (!term || !dictionary || frequency === null) { if (!term || !dictionary || frequency === null) {
return null; return null;
@@ -184,17 +201,20 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
const reading = const reading =
value.reading === null ? null : typeof value.reading === 'string' ? value.reading : null; value.reading === null ? null : typeof value.reading === 'string' ? value.reading : null;
const hasReading = value.hasReading === false ? false : reading !== null;
const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null; const displayValue = typeof displayValueRaw === 'string' ? displayValueRaw : null;
const displayValueParsed = value.displayValueParsed === true; const displayValueParsed = value.displayValueParsed === true;
return { return {
term, term,
reading, reading,
hasReading,
dictionary, dictionary,
dictionaryPriority, dictionaryPriority,
frequency, frequency,
displayValue, displayValue,
displayValueParsed, displayValueParsed,
frequencyDerivedFromDisplayValue: parsedDisplayFrequency !== null,
}; };
} }
@@ -300,17 +320,34 @@ function toYomitanProfileMetadata(value: unknown): YomitanProfileMetadata | null
} }
} }
const dictionaryFrequencyModeByNameRaw = value.dictionaryFrequencyModeByName;
const dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>> = {};
if (isObject(dictionaryFrequencyModeByNameRaw)) {
for (const [name, frequencyModeRaw] of Object.entries(dictionaryFrequencyModeByNameRaw)) {
const normalizedName = name.trim();
if (!normalizedName) {
continue;
}
if (frequencyModeRaw !== 'occurrence-based' && frequencyModeRaw !== 'rank-based') {
continue;
}
dictionaryFrequencyModeByName[normalizedName] = frequencyModeRaw;
}
}
return { return {
profileIndex, profileIndex,
scanLength, scanLength,
dictionaries, dictionaries,
dictionaryPriorityByName, dictionaryPriorityByName,
dictionaryFrequencyModeByName,
}; };
} }
function normalizeFrequencyEntriesWithPriority( function normalizeFrequencyEntriesWithPriority(
rawResult: unknown[], rawResult: unknown[],
dictionaryPriorityByName: Record<string, number>, dictionaryPriorityByName: Record<string, number>,
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>,
): YomitanTermFrequency[] { ): YomitanTermFrequency[] {
const normalized: YomitanTermFrequency[] = []; const normalized: YomitanTermFrequency[] = [];
for (const entry of rawResult) { for (const entry of rawResult) {
@@ -319,6 +356,10 @@ function normalizeFrequencyEntriesWithPriority(
continue; continue;
} }
if (dictionaryFrequencyModeByName[frequency.dictionary] === 'occurrence-based') {
continue;
}
const dictionaryPriority = dictionaryPriorityByName[frequency.dictionary]; const dictionaryPriority = dictionaryPriorityByName[frequency.dictionary];
normalized.push({ normalized.push({
...frequency, ...frequency,
@@ -425,8 +466,34 @@ async function requestYomitanProfileMetadata(
acc[entry.name] = index; acc[entry.name] = index;
return acc; return acc;
}, {}); }, {});
let dictionaryFrequencyModeByName = {};
try {
const dictionaryInfo = await invoke("getDictionaryInfo", undefined);
dictionaryFrequencyModeByName = Array.isArray(dictionaryInfo)
? dictionaryInfo.reduce((acc, entry) => {
if (!entry || typeof entry !== "object" || typeof entry.title !== "string") {
return acc;
}
if (
entry.frequencyMode === "occurrence-based" ||
entry.frequencyMode === "rank-based"
) {
acc[entry.title] = entry.frequencyMode;
}
return acc;
}, {})
: {};
} catch {
dictionaryFrequencyModeByName = {};
}
return { profileIndex, scanLength, dictionaries, dictionaryPriorityByName }; return {
profileIndex,
scanLength,
dictionaries,
dictionaryPriorityByName,
dictionaryFrequencyModeByName
};
})(); })();
`; `;
@@ -774,7 +841,133 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
} }
return segments; return segments;
} }
function getPreferredHeadword(dictionaryEntries, token) { function parsePositiveFrequencyNumber(value) {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.max(1, Math.floor(value));
}
if (typeof value === 'string') {
const numericMatch = value.trim().match(/[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/)?.[0];
if (!numericMatch) { return null; }
const parsed = Number.parseFloat(numericMatch);
if (!Number.isFinite(parsed) || parsed <= 0) { return null; }
return Math.max(1, Math.floor(parsed));
}
if (Array.isArray(value)) {
for (const item of value) {
const parsed = parsePositiveFrequencyNumber(item);
if (parsed !== null) { return parsed; }
}
}
return null;
}
function parseDisplayFrequencyNumber(value) {
if (typeof value === 'string') {
const leadingDigits = value.trim().match(/^\d+/)?.[0];
if (!leadingDigits) { return null; }
const parsed = Number.parseInt(leadingDigits, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return parsePositiveFrequencyNumber(value);
}
function getFrequencyDictionaryName(frequency) {
const candidates = [
frequency?.dictionary,
frequency?.dictionaryName,
frequency?.name,
frequency?.title,
frequency?.dictionaryTitle,
frequency?.dictionaryAlias
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return null;
}
function getBestFrequencyRank(dictionaryEntry, headwordIndex, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
let best = null;
const headwordCount = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords.length : 0;
for (const frequency of dictionaryEntry?.frequencies || []) {
if (!frequency || typeof frequency !== 'object') { continue; }
const frequencyHeadwordIndex = frequency.headwordIndex;
if (typeof frequencyHeadwordIndex === 'number') {
if (frequencyHeadwordIndex !== headwordIndex) { continue; }
} else if (headwordCount > 1) {
continue;
}
const dictionary = getFrequencyDictionaryName(frequency);
if (!dictionary) { continue; }
if (dictionaryFrequencyModeByName[dictionary] === 'occurrence-based') { continue; }
const rank =
parseDisplayFrequencyNumber(frequency.displayValue) ??
parsePositiveFrequencyNumber(frequency.frequency);
if (rank === null) { continue; }
const priorityRaw = dictionaryPriorityByName[dictionary];
const fallbackPriority =
typeof frequency.dictionaryIndex === 'number' && Number.isFinite(frequency.dictionaryIndex)
? Math.max(0, Math.floor(frequency.dictionaryIndex))
: Number.MAX_SAFE_INTEGER;
const priority =
typeof priorityRaw === 'number' && Number.isFinite(priorityRaw)
? Math.max(0, Math.floor(priorityRaw))
: fallbackPriority;
if (best === null || priority < best.priority || (priority === best.priority && rank < best.rank)) {
best = { priority, rank };
}
}
return best?.rank ?? null;
}
function hasExactSource(headword, token, requirePrimary) {
for (const src of headword.sources || []) {
if (src.originalText !== token) { continue; }
if (requirePrimary && !src.isPrimary) { continue; }
if (src.matchType !== 'exact') { continue; }
return true;
}
return false;
}
function collectExactHeadwordMatches(dictionaryEntries, token, requirePrimary) {
const matches = [];
for (const dictionaryEntry of dictionaryEntries || []) {
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
for (let headwordIndex = 0; headwordIndex < headwords.length; headwordIndex += 1) {
const headword = headwords[headwordIndex];
if (!hasExactSource(headword, token, requirePrimary)) { continue; }
matches.push({ dictionaryEntry, headword, headwordIndex });
}
}
return matches;
}
function sameHeadword(match, preferredMatch) {
if (!match || !preferredMatch) {
return false;
}
if (match.headword?.term !== preferredMatch.headword?.term) {
return false;
}
const matchReading = typeof match.headword?.reading === 'string' ? match.headword.reading : '';
const preferredReading =
typeof preferredMatch.headword?.reading === 'string' ? preferredMatch.headword.reading : '';
return matchReading === preferredReading;
}
function getBestFrequencyRankForMatches(matches, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
let best = null;
for (const match of matches) {
const rank = getBestFrequencyRank(
match.dictionaryEntry,
match.headwordIndex,
dictionaryPriorityByName,
dictionaryFrequencyModeByName
);
if (rank === null) { continue; }
if (best === null || rank < best) {
best = rank;
}
}
return best;
}
function getPreferredHeadword(dictionaryEntries, token, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
function appendDictionaryNames(target, value) { function appendDictionaryNames(target, value) {
if (!value || typeof value !== 'object') { if (!value || typeof value !== 'object') {
return; return;
@@ -813,37 +1006,34 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
} }
return getDictionaryEntryNames(entry).some((name) => name.startsWith("SubMiner Character Dictionary")); return getDictionaryEntryNames(entry).some((name) => name.startsWith("SubMiner Character Dictionary"));
} }
function hasExactPrimarySource(headword, token) { const exactPrimaryMatches = collectExactHeadwordMatches(dictionaryEntries, token, true);
for (const src of headword.sources || []) {
if (src.originalText !== token) { continue; }
if (!src.isPrimary) { continue; }
if (src.matchType !== 'exact') { continue; }
return true;
}
return false;
}
let matchedNameDictionary = false; let matchedNameDictionary = false;
if (includeNameMatchMetadata) { if (includeNameMatchMetadata) {
for (const dictionaryEntry of dictionaryEntries || []) { for (const dictionaryEntry of dictionaryEntries || []) {
if (!isNameDictionaryEntry(dictionaryEntry)) { continue; } if (!isNameDictionaryEntry(dictionaryEntry)) { continue; }
for (const headword of dictionaryEntry.headwords || []) { for (const match of exactPrimaryMatches) {
if (!hasExactPrimarySource(headword, token)) { continue; } if (match.dictionaryEntry !== dictionaryEntry) { continue; }
matchedNameDictionary = true; matchedNameDictionary = true;
break; break;
} }
if (matchedNameDictionary) { break; } if (matchedNameDictionary) { break; }
} }
} }
for (const dictionaryEntry of dictionaryEntries || []) { const preferredMatch = exactPrimaryMatches[0];
for (const headword of dictionaryEntry.headwords || []) { if (preferredMatch) {
if (!hasExactPrimarySource(headword, token)) { continue; } const exactFrequencyMatches = collectExactHeadwordMatches(dictionaryEntries, token, false)
.filter((match) => sameHeadword(match, preferredMatch));
return { return {
term: headword.term, term: preferredMatch.headword.term,
reading: headword.reading, reading: preferredMatch.headword.reading,
isNameMatch: matchedNameDictionary || isNameDictionaryEntry(dictionaryEntry) isNameMatch: matchedNameDictionary || isNameDictionaryEntry(preferredMatch.dictionaryEntry),
frequencyRank: getBestFrequencyRankForMatches(
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
dictionaryPriorityByName,
dictionaryFrequencyModeByName
)
}; };
} }
}
return null; return null;
} }
`; `;
@@ -853,6 +1043,8 @@ function buildYomitanScanningScript(
profileIndex: number, profileIndex: number,
scanLength: number, scanLength: number,
includeNameMatchMetadata: boolean, includeNameMatchMetadata: boolean,
dictionaryPriorityByName: Record<string, number>,
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>,
): string { ): string {
return ` return `
(async () => { (async () => {
@@ -876,6 +1068,8 @@ function buildYomitanScanningScript(
}); });
${YOMITAN_SCANNING_HELPERS} ${YOMITAN_SCANNING_HELPERS}
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'}; const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
const dictionaryPriorityByName = ${JSON.stringify(dictionaryPriorityByName)};
const dictionaryFrequencyModeByName = ${JSON.stringify(dictionaryFrequencyModeByName)};
const text = ${JSON.stringify(text)}; const text = ${JSON.stringify(text)};
const details = {matchType: "exact", deinflect: true}; const details = {matchType: "exact", deinflect: true};
const tokens = []; const tokens = [];
@@ -889,7 +1083,12 @@ ${YOMITAN_SCANNING_HELPERS}
const originalTextLength = typeof result?.originalTextLength === "number" ? result.originalTextLength : 0; const originalTextLength = typeof result?.originalTextLength === "number" ? result.originalTextLength : 0;
if (dictionaryEntries.length > 0 && originalTextLength > 0 && (originalTextLength !== character.length || isCodePointJapanese(codePoint))) { if (dictionaryEntries.length > 0 && originalTextLength > 0 && (originalTextLength !== character.length || isCodePointJapanese(codePoint))) {
const source = substring.substring(0, originalTextLength); const source = substring.substring(0, originalTextLength);
const preferredHeadword = getPreferredHeadword(dictionaryEntries, source); const preferredHeadword = getPreferredHeadword(
dictionaryEntries,
source,
dictionaryPriorityByName,
dictionaryFrequencyModeByName
);
if (preferredHeadword && typeof preferredHeadword.term === "string") { if (preferredHeadword && typeof preferredHeadword.term === "string") {
const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : ""; const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : "";
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source); const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
@@ -900,6 +1099,10 @@ ${YOMITAN_SCANNING_HELPERS}
startPos: i, startPos: i,
endPos: i + originalTextLength, endPos: i + originalTextLength,
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true, isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
frequencyRank:
typeof preferredHeadword.frequencyRank === "number" && Number.isFinite(preferredHeadword.frequencyRank)
? Math.max(1, Math.floor(preferredHeadword.frequencyRank))
: undefined,
}); });
i += originalTextLength; i += originalTextLength;
continue; continue;
@@ -1036,6 +1239,8 @@ export async function requestYomitanScanTokens(
profileIndex, profileIndex,
scanLength, scanLength,
options?.includeNameMatchMetadata === true, options?.includeNameMatchMetadata === true,
metadata?.dictionaryPriorityByName ?? {},
metadata?.dictionaryFrequencyModeByName ?? {},
), ),
true, true,
); );
@@ -1099,7 +1304,11 @@ async function fetchYomitanTermFrequencies(
try { try {
const rawResult = await parserWindow.webContents.executeJavaScript(script, true); const rawResult = await parserWindow.webContents.executeJavaScript(script, true);
return Array.isArray(rawResult) return Array.isArray(rawResult)
? normalizeFrequencyEntriesWithPriority(rawResult, metadata.dictionaryPriorityByName) ? normalizeFrequencyEntriesWithPriority(
rawResult,
metadata.dictionaryPriorityByName,
metadata.dictionaryFrequencyModeByName,
)
: []; : [];
} catch (err) { } catch (err) {
logger.error('Yomitan term frequency request failed:', (err as Error).message); logger.error('Yomitan term frequency request failed:', (err as Error).message);
@@ -1541,10 +1750,15 @@ export async function getYomitanDictionaryInfo(
.map((entry) => { .map((entry) => {
const title = typeof entry.title === 'string' ? entry.title.trim() : ''; const title = typeof entry.title === 'string' ? entry.title.trim() : '';
const revision = entry.revision; const revision = entry.revision;
const frequencyMode: YomitanFrequencyMode | undefined =
entry.frequencyMode === 'occurrence-based' || entry.frequencyMode === 'rank-based'
? entry.frequencyMode
: undefined;
return { return {
title, title,
revision: revision:
typeof revision === 'string' || typeof revision === 'number' ? revision : undefined, typeof revision === 'string' || typeof revision === 'number' ? revision : undefined,
frequencyMode,
}; };
}) })
.filter((entry) => entry.title.length > 0); .filter((entry) => entry.title.length > 0);

View File

@@ -1672,7 +1672,7 @@ function shouldInitializeMecabForAnnotations(): boolean {
const config = getResolvedConfig(); const config = getResolvedConfig();
const nPlusOneEnabled = getRuntimeBooleanOption( const nPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne', 'subtitle.annotation.nPlusOne',
config.ankiConnect.nPlusOne.highlightEnabled, config.ankiConnect.knownWords.highlightEnabled,
); );
const jlptEnabled = getRuntimeBooleanOption( const jlptEnabled = getRuntimeBooleanOption(
'subtitle.annotation.jlpt', 'subtitle.annotation.jlpt',
@@ -2511,6 +2511,7 @@ const ensureStatsServerStarted = (): string => {
port: getResolvedConfig().stats.serverPort, port: getResolvedConfig().stats.serverPort,
staticDir: statsDistPath, staticDir: statsDistPath,
tracker, tracker,
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
}); });
appState.statsServer = statsServer; appState.statsServer = statsServer;
} }
@@ -2576,6 +2577,7 @@ const immersionTrackerStartupMainDeps: Parameters<
registerStatsOverlayToggle({ registerStatsOverlayToggle({
staticDir: statsDistPath, staticDir: statsDistPath,
preloadPath: statsPreloadPath, preloadPath: statsPreloadPath,
getApiBaseUrl: () => ensureStatsServerStarted(),
getToggleKey: () => getResolvedConfig().stats.toggleKey, getToggleKey: () => getResolvedConfig().stats.toggleKey,
resolveBounds: () => getCurrentOverlayGeometry(), resolveBounds: () => getCurrentOverlayGeometry(),
}); });
@@ -3058,11 +3060,11 @@ const {
}, },
getKnownWordMatchMode: () => getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ?? appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.nPlusOne.matchMode, getResolvedConfig().ankiConnect.knownWords.matchMode,
getNPlusOneEnabled: () => getNPlusOneEnabled: () =>
getRuntimeBooleanOption( getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne', 'subtitle.annotation.nPlusOne',
getResolvedConfig().ankiConnect.nPlusOne.highlightEnabled, getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
), ),
getMinSentenceWordsForNPlusOne: () => getMinSentenceWordsForNPlusOne: () =>
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,

View File

@@ -25,7 +25,7 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
return { return {
...config.subtitleStyle, ...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne, nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.nPlusOne.knownWord, knownWordColor: config.ankiConnect.knownWords.color,
nameMatchColor: config.subtitleStyle.nameMatchColor, nameMatchColor: config.subtitleStyle.nameMatchColor,
enableJlpt: config.subtitleStyle.enableJlpt, enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary, frequencyDictionary: config.subtitleStyle.frequencyDictionary,

View File

@@ -9,7 +9,7 @@ function makeHandler(overrides: Partial<Parameters<typeof createRunStatsCliComma
const handler = createRunStatsCliCommandHandler({ const handler = createRunStatsCliCommandHandler({
getResolvedConfig: () => ({ getResolvedConfig: () => ({
immersionTracking: { enabled: true }, immersionTracking: { enabled: true },
stats: { serverPort: 5175 }, stats: { serverPort: 6969 },
}), }),
ensureImmersionTrackerStarted: () => { ensureImmersionTrackerStarted: () => {
calls.push('ensureImmersionTrackerStarted'); calls.push('ensureImmersionTrackerStarted');
@@ -17,7 +17,7 @@ function makeHandler(overrides: Partial<Parameters<typeof createRunStatsCliComma
getImmersionTracker: () => ({ cleanupVocabularyStats: undefined }), getImmersionTracker: () => ({ cleanupVocabularyStats: undefined }),
ensureStatsServerStarted: () => { ensureStatsServerStarted: () => {
calls.push('ensureStatsServerStarted'); calls.push('ensureStatsServerStarted');
return 'http://127.0.0.1:5175'; return 'http://127.0.0.1:6969';
}, },
openExternal: async (url) => { openExternal: async (url) => {
calls.push(`openExternal:${url}`); calls.push(`openExternal:${url}`);
@@ -51,13 +51,13 @@ test('stats cli command starts tracker, server, browser, and writes success resp
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'ensureImmersionTrackerStarted', 'ensureImmersionTrackerStarted',
'ensureStatsServerStarted', 'ensureStatsServerStarted',
'openExternal:http://127.0.0.1:5175', 'openExternal:http://127.0.0.1:6969',
'info:Stats dashboard available at http://127.0.0.1:5175', 'info:Stats dashboard available at http://127.0.0.1:6969',
]); ]);
assert.deepEqual(responses, [ assert.deepEqual(responses, [
{ {
responsePath: '/tmp/subminer-stats-response.json', responsePath: '/tmp/subminer-stats-response.json',
payload: { ok: true, url: 'http://127.0.0.1:5175' }, payload: { ok: true, url: 'http://127.0.0.1:6969' },
}, },
]); ]);
}); });
@@ -66,7 +66,7 @@ test('stats cli command fails when immersion tracking is disabled', async () =>
const { handler, calls, responses } = makeHandler({ const { handler, calls, responses } = makeHandler({
getResolvedConfig: () => ({ getResolvedConfig: () => ({
immersionTracking: { enabled: false }, immersionTracking: { enabled: false },
stats: { serverPort: 5175 }, stats: { serverPort: 6969 },
}), }),
}); });

View File

@@ -169,13 +169,17 @@ export function mergeTokens(
isKnownWord: (text: string) => boolean = () => false, isKnownWord: (text: string) => boolean = () => false,
knownWordMatchMode: 'headword' | 'surface' = 'headword', knownWordMatchMode: 'headword' | 'surface' = 'headword',
shouldLookupKnownWords = true, shouldLookupKnownWords = true,
sourceText?: string,
): MergedToken[] { ): MergedToken[] {
if (!tokens || tokens.length === 0) { if (!tokens || tokens.length === 0) {
return []; return [];
} }
const result: MergedToken[] = []; const result: MergedToken[] = [];
const normalizedSourceText =
typeof sourceText === 'string' ? sourceText.replace(/\r?\n/g, ' ').trim() : null;
let charOffset = 0; let charOffset = 0;
let sourceCursor = 0;
let lastStandaloneToken: Token | null = null; let lastStandaloneToken: Token | null = null;
const resolveKnownMatch = (text: string | undefined): boolean => { const resolveKnownMatch = (text: string | undefined): boolean => {
if (!shouldLookupKnownWords || !text) { if (!shouldLookupKnownWords || !text) {
@@ -185,9 +189,12 @@ export function mergeTokens(
}; };
for (const token of tokens) { for (const token of tokens) {
const start = charOffset; const matchedStart =
const end = charOffset + token.word.length; normalizedSourceText !== null ? normalizedSourceText.indexOf(token.word, sourceCursor) : -1;
const start = matchedStart >= sourceCursor ? matchedStart : charOffset;
const end = start + token.word.length;
charOffset = end; charOffset = end;
sourceCursor = end;
let shouldMergeToken = false; let shouldMergeToken = false;

View File

@@ -244,13 +244,15 @@ export interface AnkiConnectConfig {
fallbackDuration?: number; fallbackDuration?: number;
maxMediaDuration?: number; maxMediaDuration?: number;
}; };
nPlusOne?: { knownWords?: {
highlightEnabled?: boolean; highlightEnabled?: boolean;
refreshMinutes?: number; refreshMinutes?: number;
matchMode?: NPlusOneMatchMode; matchMode?: NPlusOneMatchMode;
decks?: string[]; decks?: string[];
color?: string;
};
nPlusOne?: {
nPlusOne?: string; nPlusOne?: string;
knownWord?: string;
minSentenceWords?: number; minSentenceWords?: number;
}; };
behavior?: { behavior?: {
@@ -733,13 +735,15 @@ export interface ResolvedConfig {
fallbackDuration: number; fallbackDuration: number;
maxMediaDuration: number; maxMediaDuration: number;
}; };
nPlusOne: { knownWords: {
highlightEnabled: boolean; highlightEnabled: boolean;
refreshMinutes: number; refreshMinutes: number;
matchMode: NPlusOneMatchMode; matchMode: NPlusOneMatchMode;
decks: string[]; decks: string[];
color: string;
};
nPlusOne: {
nPlusOne: string; nPlusOne: string;
knownWord: string;
minSentenceWords: number; minSentenceWords: number;
}; };
behavior: { behavior: {

View File

@@ -5,6 +5,8 @@
"": { "": {
"name": "@subminer/stats-ui", "name": "@subminer/stats-ui",
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^2.15.0", "recharts": "^2.15.0",
@@ -113,6 +115,10 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],

View File

@@ -8,6 +8,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^2.15.0" "recharts": "^2.15.0"

View File

@@ -42,12 +42,12 @@ export function App() {
</header> </header>
<main className="flex-1 overflow-y-auto p-4"> <main className="flex-1 overflow-y-auto p-4">
{activeTab === 'overview' ? ( {activeTab === 'overview' ? (
<section id="panel-overview" role="tabpanel" aria-labelledby="tab-overview"> <section id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" key="overview" className="animate-fade-in">
<OverviewTab /> <OverviewTab />
</section> </section>
) : null} ) : null}
{activeTab === 'anime' ? ( {activeTab === 'anime' ? (
<section id="panel-anime" role="tabpanel" aria-labelledby="tab-anime"> <section id="panel-anime" role="tabpanel" aria-labelledby="tab-anime" key="anime" className="animate-fade-in">
<AnimeTab <AnimeTab
initialAnimeId={selectedAnimeId} initialAnimeId={selectedAnimeId}
onClearInitialAnime={() => setSelectedAnimeId(null)} onClearInitialAnime={() => setSelectedAnimeId(null)}
@@ -56,12 +56,12 @@ export function App() {
</section> </section>
) : null} ) : null}
{activeTab === 'trends' ? ( {activeTab === 'trends' ? (
<section id="panel-trends" role="tabpanel" aria-labelledby="tab-trends"> <section id="panel-trends" role="tabpanel" aria-labelledby="tab-trends" key="trends" className="animate-fade-in">
<TrendsTab /> <TrendsTab />
</section> </section>
) : null} ) : null}
{activeTab === 'vocabulary' ? ( {activeTab === 'vocabulary' ? (
<section id="panel-vocabulary" role="tabpanel" aria-labelledby="tab-vocabulary"> <section id="panel-vocabulary" role="tabpanel" aria-labelledby="tab-vocabulary" key="vocabulary" className="animate-fade-in">
<VocabularyTab <VocabularyTab
onNavigateToAnime={navigateToAnime} onNavigateToAnime={navigateToAnime}
onOpenWordDetail={openWordDetail} onOpenWordDetail={openWordDetail}
@@ -69,7 +69,7 @@ export function App() {
</section> </section>
) : null} ) : null}
{activeTab === 'sessions' ? ( {activeTab === 'sessions' ? (
<section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions"> <section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" key="sessions" className="animate-fade-in">
<SessionsTab /> <SessionsTab />
</section> </section>
) : null} ) : null}

View File

@@ -0,0 +1,143 @@
import { useState, useEffect, useRef } from 'react';
import { apiClient } from '../../lib/api-client';
interface AnilistMedia {
id: number;
episodes: number | null;
season: string | null;
seasonYear: number | null;
description: string | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}
interface AnilistSelectorProps {
animeId: number;
initialQuery: string;
onClose: () => void;
onLinked: () => void;
}
export function AnilistSelector({ animeId, initialQuery, onClose, onLinked }: AnilistSelectorProps) {
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<AnilistMedia[]>([]);
const [loading, setLoading] = useState(false);
const [linking, setLinking] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
inputRef.current?.focus();
if (initialQuery) doSearch(initialQuery);
}, []);
const doSearch = async (q: string) => {
if (!q.trim()) { setResults([]); return; }
setLoading(true);
try {
const data = await apiClient.searchAnilist(q.trim());
setResults(data);
} catch {
setResults([]);
}
setLoading(false);
};
const handleInput = (value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 400);
};
const handleSelect = async (media: AnilistMedia) => {
setLinking(media.id);
try {
await apiClient.reassignAnimeAnilist(animeId, {
anilistId: media.id,
titleRomaji: media.title?.romaji ?? null,
titleEnglish: media.title?.english ?? null,
titleNative: media.title?.native ?? null,
episodesTotal: media.episodes ?? null,
description: media.description ?? null,
coverUrl: media.coverImage?.large ?? media.coverImage?.medium ?? null,
});
onLinked();
} catch {
setLinking(null);
}
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-ctp-surface1">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">Select AniList Entry</h3>
<button
type="button"
onClick={onClose}
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
>
{'\u2715'}
</button>
</div>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleInput(e.target.value)}
placeholder="Search AniList..."
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
</div>
<div className="flex-1 overflow-y-auto p-2">
{loading && <div className="text-xs text-ctp-overlay2 p-3">Searching...</div>}
{!loading && results.length === 0 && query.trim() && (
<div className="text-xs text-ctp-overlay2 p-3">No results</div>
)}
{results.map((media) => (
<button
key={media.id}
type="button"
disabled={linking !== null}
onClick={() => void handleSelect(media)}
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
>
{media.coverImage?.medium ? (
<img
src={media.coverImage.medium}
alt=""
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface1"
/>
) : (
<div className="w-10 h-14 rounded bg-ctp-surface1 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">
{media.title?.romaji ?? media.title?.english ?? 'Unknown'}
</div>
{media.title?.english && media.title.english !== media.title.romaji && (
<div className="text-xs text-ctp-subtext0 truncate">{media.title.english}</div>
)}
<div className="text-xs text-ctp-overlay2 mt-0.5">
{media.episodes ? `${media.episodes} eps` : 'Unknown eps'}
{media.seasonYear ? ` · ${media.season ?? ''} ${media.seasonYear}` : ''}
</div>
</div>
{linking === media.id ? (
<span className="text-xs text-ctp-blue shrink-0">Linking...</span>
) : (
<span className="text-xs text-ctp-overlay2 shrink-0">Select</span>
)}
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { StatCard } from '../layout/StatCard';
import { AnimeHeader } from './AnimeHeader'; import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList'; import { EpisodeList } from './EpisodeList';
import { AnimeWordList } from './AnimeWordList'; import { AnimeWordList } from './AnimeWordList';
import { AnilistSelector } from './AnilistSelector';
import { CHART_THEME } from '../../lib/chart-theme'; import { CHART_THEME } from '../../lib/chart-theme';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import type { DailyRollup } from '../../types/stats'; import type { DailyRollup } from '../../types/stats';
@@ -95,7 +96,8 @@ function AnimeWatchChart({ animeId }: { animeId: number }) {
} }
export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDetailViewProps) { export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDetailViewProps) {
const { data, loading, error } = useAnimeDetail(animeId); const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
@@ -115,7 +117,11 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
> >
&larr; Back to Anime &larr; Back to Anime
</button> </button>
<AnimeHeader detail={detail} anilistEntries={anilistEntries ?? []} /> <AnimeHeader
detail={detail}
anilistEntries={anilistEntries ?? []}
onChangeAnilist={() => setShowAnilistSelector(true)}
/>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
<StatCard label="Watch Time" value={formatDuration(detail.totalActiveMs)} color="text-ctp-blue" /> <StatCard label="Watch Time" value={formatDuration(detail.totalActiveMs)} color="text-ctp-blue" />
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" /> <StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" />
@@ -126,6 +132,17 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
<EpisodeList episodes={episodes} /> <EpisodeList episodes={episodes} />
<AnimeWatchChart animeId={animeId} /> <AnimeWatchChart animeId={animeId} />
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} /> <AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
{showAnilistSelector && (
<AnilistSelector
animeId={animeId}
initialQuery={detail.canonicalTitle}
onClose={() => setShowAnilistSelector(false)}
onLinked={() => {
setShowAnilistSelector(false);
reload();
}}
/>
)}
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import type { AnimeDetailData, AnilistEntry } from '../../types/stats';
interface AnimeHeaderProps { interface AnimeHeaderProps {
detail: AnimeDetailData['detail']; detail: AnimeDetailData['detail'];
anilistEntries: AnilistEntry[]; anilistEntries: AnilistEntry[];
onChangeAnilist?: () => void;
} }
function AnilistButton({ entry }: { entry: AnilistEntry }) { function AnilistButton({ entry }: { entry: AnilistEntry }) {
@@ -24,7 +25,7 @@ function AnilistButton({ entry }: { entry: AnilistEntry }) {
); );
} }
export function AnimeHeader({ detail, anilistEntries }: AnimeHeaderProps) { export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) {
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative] const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative]
.filter((t): t is string => t != null && t !== detail.canonicalTitle); .filter((t): t is string => t != null && t !== detail.canonicalTitle);
const uniqueAltTitles = [...new Set(altTitles)]; const uniqueAltTitles = [...new Set(altTitles)];
@@ -48,9 +49,9 @@ export function AnimeHeader({ detail, anilistEntries }: AnimeHeaderProps) {
<div className="text-sm text-ctp-subtext0 mt-2"> <div className="text-sm text-ctp-subtext0 mt-2">
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''} {detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''}
</div> </div>
{anilistEntries.length > 0 ? (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{hasMultipleEntries ? ( {anilistEntries.length > 0 ? (
hasMultipleEntries ? (
anilistEntries.map((entry) => ( anilistEntries.map((entry) => (
<AnilistButton key={entry.anilistId} entry={entry} /> <AnilistButton key={entry.anilistId} entry={entry} />
)) ))
@@ -63,18 +64,33 @@ export function AnimeHeader({ detail, anilistEntries }: AnimeHeaderProps) {
> >
View on AniList <span className="text-[10px]">{'\u2197'}</span> View on AniList <span className="text-[10px]">{'\u2197'}</span>
</a> </a>
)} )
</div>
) : detail.anilistId ? ( ) : detail.anilistId ? (
<a <a
href={`https://anilist.co/anime/${detail.anilistId}`} href={`https://anilist.co/anime/${detail.anilistId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-2 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
> >
View on AniList <span className="text-[10px]">{'\u2197'}</span> View on AniList <span className="text-[10px]">{'\u2197'}</span>
</a> </a>
) : null} ) : null}
{onChangeAnilist && (
<button
type="button"
onClick={onChangeAnilist}
title="Search AniList and manually select the correct anime entry"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors"
>
{anilistEntries.length > 0 || detail.anilistId ? 'Change AniList Entry' : 'Link to AniList'}
</button>
)}
</div>
{detail.description && (
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
{detail.description}
</p>
)}
</div> </div>
</div> </div>
); );

View File

@@ -92,14 +92,14 @@ export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord
<option key={opt.key} value={opt.key}>{opt.label}</option> <option key={opt.key} value={opt.key}>{opt.label}</option>
))} ))}
</select> </select>
<div className="flex gap-1 shrink-0"> <div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0">
{(['sm', 'md', 'lg'] as const).map((size) => ( {(['sm', 'md', 'lg'] as const).map((size) => (
<button <button
key={size} key={size}
onClick={() => setCardSize(size)} onClick={() => setCardSize(size)}
className={`px-2 py-1 rounded text-xs ${ className={`px-2 py-1 rounded-md text-xs transition-colors ${
cardSize === size cardSize === size
? 'bg-ctp-surface2 text-ctp-text' ? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0' : 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`} }`}
> >

View File

@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getStatsClient } from '../../hooks/useStatsApi'; import { getStatsClient } from '../../hooks/useStatsApi';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters'; import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import type { EpisodeDetailData } from '../../types/stats'; import type { EpisodeDetailData } from '../../types/stats';
interface EpisodeDetailProps { interface EpisodeDetailProps {
videoId: number; videoId: number;
onSessionDeleted?: () => void;
} }
interface NoteInfo { interface NoteInfo {
@@ -12,7 +15,7 @@ interface NoteInfo {
expression: string; expression: string;
} }
export function EpisodeDetail({ videoId }: EpisodeDetailProps) { export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
const [data, setData] = useState<EpisodeDetailData | null>(null); const [data, setData] = useState<EpisodeDetailData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map()); const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
@@ -46,13 +49,30 @@ export function EpisodeDetail({ videoId }: EpisodeDetailProps) {
.catch((err) => console.warn('Failed to fetch Anki note info:', err)); .catch((err) => console.warn('Failed to fetch Anki note info:', err));
} }
}) })
.catch(() => { if (!cancelled) setData(null); }) .catch(() => {
.finally(() => { if (!cancelled) setLoading(false); }); if (!cancelled) setData(null);
return () => { cancelled = true; }; })
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [videoId]); }, [videoId]);
const handleDeleteSession = async (sessionId: number) => {
if (!confirmSessionDelete()) return;
await apiClient.deleteSession(sessionId);
setData((prev) => {
if (!prev) return prev;
return { ...prev, sessions: prev.sessions.filter((s) => s.sessionId !== sessionId) };
});
onSessionDeleted?.();
};
if (loading) return <div className="text-ctp-overlay2 text-xs p-3">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 text-xs p-3">Loading...</div>;
if (!data) return <div className="text-ctp-overlay2 text-xs p-3">Failed to load episode details.</div>; if (!data)
return <div className="text-ctp-overlay2 text-xs p-3">Failed to load episode details.</div>;
const { sessions, cardEvents } = data; const { sessions, cardEvents } = data;
@@ -63,13 +83,24 @@ export function EpisodeDetail({ videoId }: EpisodeDetailProps) {
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Sessions</h4> <h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Sessions</h4>
<div className="space-y-1"> <div className="space-y-1">
{sessions.map((s) => ( {sessions.map((s) => (
<div key={s.sessionId} className="flex items-center gap-3 text-xs"> <div key={s.sessionId} className="flex items-center gap-3 text-xs group">
<span className="text-ctp-overlay2"> <span className="text-ctp-overlay2">
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'} {s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
</span> </span>
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span> <span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
<span className="text-ctp-green">{formatNumber(s.cardsMined)} cards</span> <span className="text-ctp-green">{formatNumber(s.cardsMined)} cards</span>
<span className="text-ctp-peach">{formatNumber(s.wordsSeen)} words</span> <span className="text-ctp-peach">{formatNumber(s.wordsSeen)} words</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteSession(s.sessionId);
}}
className="ml-auto opacity-0 group-hover:opacity-100 text-ctp-red/70 hover:text-ctp-red transition-opacity text-[10px] px-1.5 py-0.5 rounded hover:bg-ctp-red/10"
title="Delete session"
>
Delete
</button>
</div> </div>
))} ))}
</div> </div>
@@ -82,16 +113,16 @@ export function EpisodeDetail({ videoId }: EpisodeDetailProps) {
<div className="space-y-1.5"> <div className="space-y-1.5">
{cardEvents.map((ev) => ( {cardEvents.map((ev) => (
<div key={ev.eventId} className="flex items-center gap-2 text-xs"> <div key={ev.eventId} className="flex items-center gap-2 text-xs">
<span className="text-ctp-overlay2 shrink-0"> <span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
{formatRelativeDate(ev.tsMs)}
</span>
{ev.noteIds.length > 0 ? ( {ev.noteIds.length > 0 ? (
ev.noteIds.map((noteId) => { ev.noteIds.map((noteId) => {
const info = noteInfos.get(noteId); const info = noteInfos.get(noteId);
return ( return (
<div key={noteId} className="flex items-center gap-2 min-w-0 flex-1"> <div key={noteId} className="flex items-center gap-2 min-w-0 flex-1">
{info?.expression && ( {info?.expression && (
<span className="text-ctp-text font-medium truncate">{info.expression}</span> <span className="text-ctp-text font-medium truncate">
{info.expression}
</span>
)} )}
<button <button
type="button" type="button"

View File

@@ -1,14 +1,16 @@
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters'; import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import { apiClient } from '../../lib/api-client'; import { apiClient } from '../../lib/api-client';
import { confirmEpisodeDelete } from '../../lib/delete-confirm';
import { EpisodeDetail } from './EpisodeDetail'; import { EpisodeDetail } from './EpisodeDetail';
import type { AnimeEpisode } from '../../types/stats'; import type { AnimeEpisode } from '../../types/stats';
interface EpisodeListProps { interface EpisodeListProps {
episodes: AnimeEpisode[]; episodes: AnimeEpisode[];
onEpisodeDeleted?: () => void;
} }
export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) { export function EpisodeList({ episodes: initialEpisodes, onEpisodeDeleted }: EpisodeListProps) {
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null); const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
const [episodes, setEpisodes] = useState(initialEpisodes); const [episodes, setEpisodes] = useState(initialEpisodes);
@@ -35,6 +37,14 @@ export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) {
} }
}; };
const handleDeleteEpisode = async (videoId: number, title: string) => {
if (!confirmEpisodeDelete(title)) return;
await apiClient.deleteVideo(videoId);
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
if (expandedVideoId === videoId) setExpandedVideoId(null);
onEpisodeDeleted?.();
};
const watchedCount = episodes.filter((ep) => ep.watched).length; const watchedCount = episodes.filter((ep) => ep.watched).length;
return ( return (
@@ -56,34 +66,36 @@ export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) {
<th className="text-right py-2 pr-3 font-medium">Watch Time</th> <th className="text-right py-2 pr-3 font-medium">Watch Time</th>
<th className="text-right py-2 pr-3 font-medium">Cards</th> <th className="text-right py-2 pr-3 font-medium">Cards</th>
<th className="text-right py-2 pr-3 font-medium">Last Watched</th> <th className="text-right py-2 pr-3 font-medium">Last Watched</th>
<th className="w-8 py-2 font-medium" /> <th className="w-16 py-2 font-medium" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sorted.map((ep, idx) => ( {sorted.map((ep, idx) => (
<Fragment key={ep.videoId}> <Fragment key={ep.videoId}>
<tr <tr
onClick={() => setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)} onClick={() =>
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors" setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group"
> >
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6"> <td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'} {expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
</td> </td>
<td className="py-2 pr-3 text-ctp-subtext0"> <td className="py-2 pr-3 text-ctp-subtext0">{ep.episode ?? idx + 1}</td>
{ep.episode ?? idx + 1}
</td>
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]"> <td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
{ep.canonicalTitle} {ep.canonicalTitle}
</td> </td>
<td className="py-2 pr-3 text-right"> <td className="py-2 pr-3 text-right">
{ep.durationMs > 0 ? ( {ep.durationMs > 0 ? (
<span className={ <span
className={
ep.totalActiveMs >= ep.durationMs * 0.85 ep.totalActiveMs >= ep.durationMs * 0.85
? 'text-ctp-green' ? 'text-ctp-green'
: ep.totalActiveMs >= ep.durationMs * 0.5 : ep.totalActiveMs >= ep.durationMs * 0.5
? 'text-ctp-peach' ? 'text-ctp-peach'
: 'text-ctp-overlay2' : 'text-ctp-overlay2'
}> }
>
{Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}% {Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}%
</span> </span>
) : ( ) : (
@@ -99,7 +111,8 @@ export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) {
<td className="py-2 pr-3 text-right text-ctp-overlay2"> <td className="py-2 pr-3 text-right text-ctp-overlay2">
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'} {ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
</td> </td>
<td className="py-2 text-center w-8"> <td className="py-2 text-center w-16">
<div className="flex items-center justify-center gap-1">
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@@ -115,12 +128,24 @@ export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) {
> >
{'\u2713'} {'\u2713'}
</button> </button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
}}
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
title="Delete episode"
>
{'\u2715'}
</button>
</div>
</td> </td>
</tr> </tr>
{expandedVideoId === ep.videoId && ( {expandedVideoId === ep.videoId && (
<tr> <tr>
<td colSpan={8} className="py-2"> <td colSpan={8} className="py-2">
<EpisodeDetail videoId={ep.videoId} /> <EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
</td> </td>
</tr> </tr>
)} )}

View File

@@ -6,16 +6,35 @@ interface StatCardProps {
trend?: { direction: 'up' | 'down' | 'flat'; text: string }; trend?: { direction: 'up' | 'down' | 'flat'; text: string };
} }
const COLOR_TO_BORDER: Record<string, string> = {
'text-ctp-blue': 'border-l-ctp-blue',
'text-ctp-green': 'border-l-ctp-green',
'text-ctp-mauve': 'border-l-ctp-mauve',
'text-ctp-peach': 'border-l-ctp-peach',
'text-ctp-teal': 'border-l-ctp-teal',
'text-ctp-lavender': 'border-l-ctp-lavender',
'text-ctp-red': 'border-l-ctp-red',
'text-ctp-yellow': 'border-l-ctp-yellow',
'text-ctp-sapphire': 'border-l-ctp-sapphire',
'text-ctp-sky': 'border-l-ctp-sky',
'text-ctp-flamingo': 'border-l-ctp-flamingo',
'text-ctp-maroon': 'border-l-ctp-maroon',
'text-ctp-pink': 'border-l-ctp-pink',
'text-ctp-text': 'border-l-ctp-surface2',
};
export function StatCard({ label, value, subValue, color = 'text-ctp-text', trend }: StatCardProps) { export function StatCard({ label, value, subValue, color = 'text-ctp-text', trend }: StatCardProps) {
const borderClass = COLOR_TO_BORDER[color] ?? 'border-l-ctp-surface2';
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4 text-center"> <div className={`bg-ctp-surface0 border border-ctp-surface1 border-l-[3px] ${borderClass} rounded-lg p-4 text-center`}>
<div className={`text-2xl font-bold ${color}`}>{value}</div> <div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>{value}</div>
<div className="text-xs text-ctp-subtext0 mt-1 uppercase tracking-wide">{label}</div> <div className="text-xs text-ctp-subtext0 mt-1 uppercase tracking-wide">{label}</div>
{subValue && ( {subValue && (
<div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div> <div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div>
)} )}
{trend && ( {trend && (
<div className={`text-xs mt-1 ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}> <div className={`text-xs mt-1 font-mono tabular-nums ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}>
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'} {trend.text} {trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'} {trend.text}
</div> </div>
)} )}

View File

@@ -43,43 +43,43 @@ export function OverviewTab() {
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm"> <div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm">
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Total Sessions</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Total Sessions</div>
<div className="mt-1 text-xl font-semibold text-ctp-lavender"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
{formatNumber(summary.totalSessions)} {formatNumber(summary.totalSessions)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Today</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Today</div>
<div className="mt-1 text-xl font-semibold text-ctp-teal"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-teal">
{formatNumber(summary.episodesToday)} {formatNumber(summary.episodesToday)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">All-Time Hours</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">All-Time Hours</div>
<div className="mt-1 text-xl font-semibold text-ctp-mauve"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
{formatNumber(summary.allTimeHours)} {formatNumber(summary.allTimeHours)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
<div className="mt-1 text-xl font-semibold text-ctp-peach"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
{formatNumber(summary.activeDays)} {formatNumber(summary.activeDays)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
<div className="mt-1 text-xl font-semibold text-ctp-green"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(summary.totalTrackedCards)} {formatNumber(summary.totalTrackedCards)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Completed</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Completed</div>
<div className="mt-1 text-xl font-semibold text-ctp-blue"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)} {formatNumber(summary.totalEpisodesWatched)}
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime Completed</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime Completed</div>
<div className="mt-1 text-xl font-semibold text-ctp-sapphire"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)} {formatNumber(summary.totalAnimeCompleted)}
</div> </div>
</div> </div>

View File

@@ -121,11 +121,11 @@ function SessionItem({ session }: { session: SessionSummary }) {
</div> </div>
<div className="flex gap-4 text-xs text-center shrink-0"> <div className="flex gap-4 text-xs text-center shrink-0">
<div> <div>
<div className="text-ctp-green font-medium">{formatNumber(session.cardsMined)}</div> <div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(session.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div> <div className="text-ctp-overlay2">cards</div>
</div> </div>
<div> <div>
<div className="text-ctp-mauve font-medium">{formatNumber(session.wordsSeen)}</div> <div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(session.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>
</div> </div>
@@ -161,11 +161,11 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
</div> </div>
<div className="flex gap-4 text-xs text-center shrink-0"> <div className="flex gap-4 text-xs text-center shrink-0">
<div> <div>
<div className="text-ctp-green font-medium">{formatNumber(group.totalCards)}</div> <div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(group.totalCards)}</div>
<div className="text-ctp-overlay2">cards</div> <div className="text-ctp-overlay2">cards</div>
</div> </div>
<div> <div>
<div className="text-ctp-mauve font-medium">{formatNumber(group.totalWords)}</div> <div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(group.totalWords)}</div>
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>
</div> </div>
@@ -193,11 +193,11 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
</div> </div>
<div className="flex gap-4 text-xs text-center shrink-0"> <div className="flex gap-4 text-xs text-center shrink-0">
<div> <div>
<div className="text-ctp-green font-medium">{formatNumber(s.cardsMined)}</div> <div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(s.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div> <div className="text-ctp-overlay2">cards</div>
</div> </div>
<div> <div>
<div className="text-ctp-mauve font-medium">{formatNumber(s.wordsSeen)}</div> <div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(s.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>
</div> </div>
@@ -226,9 +226,12 @@ export function RecentSessions({ sessions }: RecentSessionsProps) {
const animeGroups = groupSessionsByAnime(daySessions); const animeGroups = groupSessionsByAnime(daySessions);
return ( return (
<div key={dayLabel}> <div key={dayLabel}>
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-wider mb-2"> <div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel} {dayLabel}
</h3> </h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2"> <div className="space-y-2">
{animeGroups.map((group) => ( {animeGroups.map((group) => (
<AnimeGroupRow key={group.key} group={group} /> <AnimeGroupRow key={group.key} group={group} />

View File

@@ -36,14 +36,14 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3> <h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
<div className="flex gap-1"> <div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1">
{ranges.map((r) => ( {ranges.map((r) => (
<button <button
key={r} key={r}
onClick={() => setRange(r)} onClick={() => setRange(r)}
className={`px-2 py-0.5 text-xs rounded ${ className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
range === r range === r
? 'bg-ctp-surface2 text-ctp-text' ? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0' : 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`} }`}
> >

View File

@@ -1,10 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client'; import { BASE_URL } from '../../lib/api-client';
import { import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
formatDuration,
formatRelativeDate,
formatNumber,
} from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
interface SessionRowProps { interface SessionRowProps {
@@ -12,6 +8,8 @@ interface SessionRowProps {
isExpanded: boolean; isExpanded: boolean;
detailsId: string; detailsId: string;
onToggle: () => void; onToggle: () => void;
onDelete: () => void;
deleteDisabled?: boolean;
} }
function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) { function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) {
@@ -37,14 +35,22 @@ function CoverThumbnail({ videoId, title }: { videoId: number | null; title: str
); );
} }
export function SessionRow({ session, isExpanded, detailsId, onToggle }: SessionRowProps) { export function SessionRow({
session,
isExpanded,
detailsId,
onToggle,
onDelete,
deleteDisabled = false,
}: SessionRowProps) {
return ( return (
<div className="relative group">
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-controls={detailsId} aria-controls={detailsId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left" className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
> >
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} /> <CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -58,11 +64,15 @@ export function SessionRow({ session, isExpanded, detailsId, onToggle }: Session
</div> </div>
<div className="flex gap-4 text-xs text-center shrink-0"> <div className="flex gap-4 text-xs text-center shrink-0">
<div> <div>
<div className="text-ctp-green font-medium">{formatNumber(session.cardsMined)}</div> <div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div> <div className="text-ctp-overlay2">cards</div>
</div> </div>
<div> <div>
<div className="text-ctp-mauve font-medium">{formatNumber(session.wordsSeen)}</div> <div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(session.wordsSeen)}
</div>
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>
</div> </div>
@@ -72,5 +82,16 @@ export function SessionRow({ session, isExpanded, detailsId, onToggle }: Session
{'\u25B8'} {'\u25B8'}
</div> </div>
</button> </button>
<button
type="button"
onClick={onDelete}
disabled={deleteDisabled}
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete session"
>
{'\u2715'}
</button>
</div>
); );
} }

View File

@@ -1,7 +1,9 @@
import { useState, useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions'; import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow'; import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail'; import { SessionDetail } from './SessionDetail';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { todayLocalDay, localDayFromMs } from '../../lib/formatters'; import { todayLocalDay, localDayFromMs } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
@@ -37,17 +39,38 @@ export function SessionsTab() {
const { sessions, loading, error } = useSessions(); const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
useEffect(() => {
setVisibleSessions(sessions);
}, [sessions]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
if (!q) return sessions; if (!q) return visibleSessions;
return sessions.filter( return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
(s) => s.canonicalTitle?.toLowerCase().includes(q), }, [visibleSessions, search]);
);
}, [sessions, search]);
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]); const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return;
setDeleteError(null);
setDeletingSessionId(session.sessionId);
try {
await apiClient.deleteSession(session.sessionId);
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
setExpandedId((prev) => (prev === session.sessionId ? null : prev));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingSessionId(null);
}
};
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
@@ -61,11 +84,16 @@ export function SessionsTab() {
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue" className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/> />
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => ( {Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
<div key={dayLabel}> <div key={dayLabel}>
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-wider mb-2"> <div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel} {dayLabel}
</h3> </h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2"> <div className="space-y-2">
{daySessions.map((s) => { {daySessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`; const detailsId = `session-details-${s.sessionId}`;
@@ -76,6 +104,8 @@ export function SessionsTab() {
isExpanded={expandedId === s.sessionId} isExpanded={expandedId === s.sessionId}
detailsId={detailsId} detailsId={detailsId}
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)} onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
/> />
{expandedId === s.sessionId && ( {expandedId === s.sessionId && (
<div id={detailsId}> <div id={detailsId}>

View File

@@ -7,52 +7,64 @@ interface DateRangeSelectorProps {
onGroupByChange: (g: GroupBy) => void; onGroupByChange: (g: GroupBy) => void;
} }
export function DateRangeSelector({ function SegmentedControl<T extends string>({
range, label,
groupBy, options,
onRangeChange, value,
onGroupByChange, onChange,
}: DateRangeSelectorProps) { formatLabel,
const ranges: TimeRange[] = ['7d', '30d', '90d', 'all']; }: {
const groups: GroupBy[] = ['day', 'month']; label: string;
options: T[];
value: T;
onChange: (v: T) => void;
formatLabel?: (v: T) => string;
}) {
return ( return (
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-2">
<div className="flex items-center gap-1.5"> <span className="text-[10px] uppercase tracking-wider text-ctp-overlay1">{label}</span>
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Range</span> <div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1">
{ranges.map((r) => ( {options.map((opt) => (
<button <button
key={r} key={opt}
onClick={() => onRangeChange(r)} onClick={() => onChange(opt)}
aria-pressed={range === r} aria-pressed={value === opt}
className={`px-2.5 py-1 rounded text-xs ${ className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
range === r value === opt
? 'bg-ctp-surface2 text-ctp-text' ? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0' : 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`} }`}
> >
{r === 'all' ? 'All' : r} {formatLabel ? formatLabel(opt) : opt}
</button>
))}
</div>
<span className="text-ctp-surface2">{'\u00B7'}</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Group by</span>
{groups.map((g) => (
<button
key={g}
onClick={() => onGroupByChange(g)}
aria-pressed={groupBy === g}
className={`px-2.5 py-1 rounded text-xs capitalize ${
groupBy === g
? 'bg-ctp-surface2 text-ctp-text'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{g}
</button> </button>
))} ))}
</div> </div>
</div> </div>
); );
} }
export function DateRangeSelector({
range,
groupBy,
onRangeChange,
onGroupByChange,
}: DateRangeSelectorProps) {
return (
<div className="flex items-center gap-4 text-sm">
<SegmentedControl
label="Range"
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
value={range}
onChange={onRangeChange}
formatLabel={(r) => r === 'all' ? 'All' : r}
/>
<SegmentedControl
label="Group by"
options={['day', 'month'] as GroupBy[]}
value={groupBy}
onChange={onGroupByChange}
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
/>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import { epochDayToDate } from '../../lib/formatters'; import { epochDayToDate } from '../../lib/formatters';
@@ -39,10 +39,15 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
const points = [...byDay.entries()] const points = [...byDay.entries()]
.sort(([a], [b]) => a - b) .sort(([a], [b]) => a - b)
.map(([epochDay, values]) => ({ .map(([epochDay, values]) => {
const row: Record<string, string | number> = {
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
...values, };
})); for (const title of topTitles) {
row[title] = values[title] ?? 0;
}
return row;
});
return { points, seriesKeys: topTitles }; return { points, seriesKeys: topTitles };
} }
@@ -67,31 +72,36 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3> <h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<ResponsiveContainer width="100%" height={120}> <ResponsiveContainer width="100%" height={120}>
<LineChart data={points}> <AreaChart data={points}>
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} /> <XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} /> <YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
<Tooltip contentStyle={tooltipStyle} /> <Tooltip contentStyle={tooltipStyle} />
{seriesKeys.map((key, i) => ( {seriesKeys.map((key, i) => (
<Line <Area
key={key} key={key}
type="monotone" type="monotone"
dataKey={key} dataKey={key}
stroke={LINE_COLORS[i % LINE_COLORS.length]} stroke={LINE_COLORS[i % LINE_COLORS.length]}
strokeWidth={2} fill={LINE_COLORS[i % LINE_COLORS.length]}
dot={false} fillOpacity={0.15}
strokeWidth={1.5}
connectNulls connectNulls
/> />
))} ))}
</LineChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2"> <div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 overflow-hidden max-h-10">
{seriesKeys.map((key, i) => ( {seriesKeys.map((key, i) => (
<span key={key} className="flex items-center gap-1 text-[10px] text-ctp-subtext0">
<span <span
className="inline-block w-2 h-2 rounded-full" key={key}
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 max-w-[140px]"
title={key}
>
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: LINE_COLORS[i % LINE_COLORS.length] }} style={{ backgroundColor: LINE_COLORS[i % LINE_COLORS.length] }}
/> />
{key} <span className="truncate">{key}</span>
</span> </span>
))} ))}
</div> </div>

View File

@@ -32,18 +32,32 @@ function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] {
function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] { function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
const byAnime = new Map<string, Map<number, number>>(); const byAnime = new Map<string, Map<number, number>>();
const allDays = new Set<number>();
for (const p of points) { for (const p of points) {
const dayMap = byAnime.get(p.animeTitle) ?? new Map(); const dayMap = byAnime.get(p.animeTitle) ?? new Map();
dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value); dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value);
byAnime.set(p.animeTitle, dayMap); byAnime.set(p.animeTitle, dayMap);
allDays.add(p.epochDay);
} }
const sortedDays = [...allDays].sort((a, b) => a - b);
if (sortedDays.length < 2) return points;
const minDay = sortedDays[0]!;
const maxDay = sortedDays[sortedDays.length - 1]!;
const everyDay: number[] = [];
for (let d = minDay; d <= maxDay; d++) {
everyDay.push(d);
}
const result: PerAnimeDataPoint[] = []; const result: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of byAnime) { for (const [animeTitle, dayMap] of byAnime) {
const sorted = [...dayMap.entries()].sort(([a], [b]) => a - b);
let cumulative = 0; let cumulative = 0;
for (const [epochDay, value] of sorted) { const firstDay = Math.min(...dayMap.keys());
cumulative += value; for (const day of everyDay) {
result.push({ epochDay, animeTitle, value: cumulative }); if (day < firstDay) continue;
cumulative += dayMap.get(day) ?? 0;
result.push({ epochDay: day, animeTitle, value: cumulative });
} }
} }
return result; return result;
@@ -93,9 +107,12 @@ function buildEpisodesPerAnimeFromSessions(sessions: SessionSummary[]): PerAnime
function SectionHeader({ children }: { children: React.ReactNode }) { function SectionHeader({ children }: { children: React.ReactNode }) {
return ( return (
<h3 className="text-ctp-subtext0 text-sm font-medium uppercase tracking-wider mt-6 mb-2 col-span-full"> <div className="col-span-full mt-6 mb-2 flex items-center gap-3">
<h3 className="text-ctp-subtext0 text-xs font-semibold uppercase tracking-widest shrink-0">
{children} {children}
</h3> </h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
); );
} }
@@ -119,6 +136,8 @@ export function TrendsTab() {
const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen); const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen);
const animeProgress = buildCumulativePerAnime(episodesPerAnime); const animeProgress = buildCumulativePerAnime(episodesPerAnime);
const cardsProgress = buildCumulativePerAnime(cardsPerAnime);
const wordsProgress = buildCumulativePerAnime(wordsPerAnime);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -141,13 +160,17 @@ export function TrendsTab() {
type="line" type="line"
/> />
<SectionHeader>Anime</SectionHeader> <SectionHeader>Anime Per Day</SectionHeader>
<StackedTrendChart title="Anime Progress (episodes)" data={animeProgress} />
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
<StackedTrendChart title="Episodes per Anime" data={episodesPerAnime} /> <StackedTrendChart title="Episodes per Anime" data={episodesPerAnime} />
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
<StackedTrendChart title="Cards Mined per Anime" data={cardsPerAnime} /> <StackedTrendChart title="Cards Mined per Anime" data={cardsPerAnime} />
<StackedTrendChart title="Words Seen per Anime" data={wordsPerAnime} /> <StackedTrendChart title="Words Seen per Anime" data={wordsPerAnime} />
<SectionHeader>Anime Cumulative</SectionHeader>
<StackedTrendChart title="Episodes Progress" data={animeProgress} />
<StackedTrendChart title="Cards Mined Progress" data={cardsProgress} />
<StackedTrendChart title="Words Seen Progress" data={wordsProgress} />
<SectionHeader>Patterns</SectionHeader> <SectionHeader>Patterns</SectionHeader>
<TrendChart title="Watch Time by Day of Week (min)" data={watchByDow} color="#8aadf4" type="bar" /> <TrendChart title="Watch Time by Day of Week (min)" data={watchByDow} color="#8aadf4" type="bar" />
<TrendChart title="Watch Time by Hour (min)" data={watchByHour} color="#c6a0f6" type="bar" /> <TrendChart title="Watch Time by Hour (min)" data={watchByHour} color="#c6a0f6" type="bar" />

View File

@@ -0,0 +1,139 @@
import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers';
import type { VocabularyEntry } from '../../types/stats';
interface FrequencyRankTableProps {
words: VocabularyEntry[];
knownWords: Set<string>;
onSelectWord?: (word: VocabularyEntry) => void;
}
const PAGE_SIZE = 25;
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const hasKnownData = knownWords.size > 0;
const isWordKnown = (w: VocabularyEntry): boolean => {
return knownWords.has(w.headword) || knownWords.has(w.word) || knownWords.has(w.reading);
};
const ranked = useMemo(() => {
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
if (hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !isWordKnown(w));
}
return filtered.sort((a, b) => a.frequencyRank! - b.frequencyRank!);
}, [words, knownWords, hideKnown, hasKnownData]);
if (words.every((w) => w.frequencyRank == null)) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-2">Most Common Words Seen</h3>
<div className="text-xs text-ctp-overlay2">
No frequency rank data available. Run the frequency backfill script or install a frequency dictionary.
</div>
</div>
);
}
const totalPages = Math.ceil(ranked.length / PAGE_SIZE);
const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
</h3>
<div className="flex items-center gap-3">
{hasKnownData && (
<button
type="button"
onClick={() => { setHideKnown(!hideKnown); setPage(0); }}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKnown
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Known
</button>
)}
<span className="text-xs text-ctp-overlay2">
{ranked.length} words
</span>
</div>
</div>
{ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2">
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
<th className="text-left py-2 pr-3 font-medium">Word</th>
<th className="text-left py-2 pr-3 font-medium">Reading</th>
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
<th className="text-right py-2 font-medium w-20">Seen</th>
</tr>
</thead>
<tbody>
{paged.map((w) => (
<tr
key={w.wordId}
onClick={() => onSelectWord?.(w)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
#{w.frequencyRank!.toLocaleString()}
</td>
<td className="py-1.5 pr-3 text-ctp-text font-medium">
{w.headword}
</td>
<td className="py-1.5 pr-3 text-ctp-subtext0">
{w.reading !== w.headword ? w.reading : ''}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 mt-3 text-xs">
<button
type="button"
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Prev
</button>
<span className="text-ctp-overlay2">{page + 1} / {totalPages}</span>
<button
type="button"
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useVocabulary } from '../../hooks/useVocabulary'; import { useVocabulary } from '../../hooks/useVocabulary';
import { StatCard } from '../layout/StatCard'; import { StatCard } from '../layout/StatCard';
import { WordList } from './WordList'; import { WordList } from './WordList';
@@ -6,6 +6,7 @@ import { KanjiBreakdown } from './KanjiBreakdown';
import { KanjiDetailPanel } from './KanjiDetailPanel'; import { KanjiDetailPanel } from './KanjiDetailPanel';
import { formatNumber } from '../../lib/formatters'; import { formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart'; import { TrendChart } from '../trends/TrendChart';
import { FrequencyRankTable } from './FrequencyRankTable';
import { buildVocabularySummary } from '../../lib/dashboard-data'; import { buildVocabularySummary } from '../../lib/dashboard-data';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats'; import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
@@ -14,10 +15,21 @@ interface VocabularyTabProps {
onOpenWordDetail?: (wordId: number) => void; onOpenWordDetail?: (wordId: number) => void;
} }
function isProperNoun(w: VocabularyEntry): boolean {
return w.pos2 === '固有名詞';
}
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) { export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) {
const { words, kanji, loading, error } = useVocabulary(); const { words, kanji, knownWords, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null); const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [hideNames, setHideNames] = useState(false);
const hasNames = useMemo(() => words.some(isProperNoun), [words]);
const filteredWords = useMemo(
() => hideNames ? words.filter((w) => !isProperNoun(w)) : words,
[words, hideNames],
);
if (loading) { if (loading) {
return ( return (
@@ -34,7 +46,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
); );
} }
const summary = buildVocabularySummary(words, kanji); const summary = buildVocabularySummary(filteredWords, kanji);
const handleSelectWord = (entry: VocabularyEntry): void => { const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId); onOpenWordDetail?.(entry.wordId);
@@ -56,14 +68,27 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
/> />
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Search words..." placeholder="Search words..."
className="rounded border border-ctp-surface2 bg-ctp-surface1 px-3 py-1 text-xs text-ctp-text placeholder:text-ctp-overlay0 focus:border-ctp-blue focus:outline-none focus:ring-1 focus:ring-ctp-blue" className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/> />
{hasNames && (
<button
type="button"
onClick={() => setHideNames(!hideNames)}
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
hideNames
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Names
</button>
)}
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
@@ -81,8 +106,10 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
/> />
</div> </div>
<FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
<WordList <WordList
words={words} words={filteredWords}
selectedKey={null} selectedKey={null}
onSelectWord={handleSelectWord} onSelectWord={handleSelectWord}
search={search} search={search}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getStatsClient } from './useStatsApi'; import { getStatsClient } from './useStatsApi';
import type { AnimeDetailData } from '../types/stats'; import type { AnimeDetailData } from '../types/stats';
@@ -6,6 +6,7 @@ export function useAnimeDetail(animeId: number | null) {
const [data, setData] = useState<AnimeDetailData | null>(null); const [data, setData] = useState<AnimeDetailData | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
if (animeId === null) return; if (animeId === null) return;
@@ -16,7 +17,9 @@ export function useAnimeDetail(animeId: number | null) {
.then(setData) .then(setData)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [animeId]); }, [animeId, reloadKey]);
return { data, loading, error }; const reload = useCallback(() => setReloadKey((k) => k + 1), []);
return { data, loading, error, reload };
} }

View File

@@ -5,6 +5,7 @@ import type { VocabularyEntry, KanjiEntry } from '../types/stats';
export function useVocabulary() { export function useVocabulary() {
const [words, setWords] = useState<VocabularyEntry[]>([]); const [words, setWords] = useState<VocabularyEntry[]>([]);
const [kanji, setKanji] = useState<KanjiEntry[]>([]); const [kanji, setKanji] = useState<KanjiEntry[]>([]);
const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -12,8 +13,8 @@ export function useVocabulary() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const client = getStatsClient(); const client = getStatsClient();
Promise.allSettled([client.getVocabulary(500), client.getKanji(200)]) Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
.then(([wordsResult, kanjiResult]) => { .then(([wordsResult, kanjiResult, knownResult]) => {
const errors: string[] = []; const errors: string[] = [];
if (wordsResult.status === 'fulfilled') { if (wordsResult.status === 'fulfilled') {
@@ -28,6 +29,10 @@ export function useVocabulary() {
errors.push(kanjiResult.reason.message); errors.push(kanjiResult.reason.message);
} }
if (knownResult.status === 'fulfilled') {
setKnownWords(new Set(knownResult.value));
}
if (errors.length > 0) { if (errors.length > 0) {
setError(errors.join('; ')); setError(errors.join('; '));
} }
@@ -35,5 +40,5 @@ export function useVocabulary() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
return { words, kanji, loading, error }; return { words, kanji, knownWords, loading, error };
} }

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client';
test('resolveStatsBaseUrl prefers apiBase query parameter for file-based overlay mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1&apiBase=http%3A%2F%2F127.0.0.1%3A6123',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl falls back to configured window origin for browser mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'http:',
origin: 'http://127.0.0.1:6123',
search: '',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without apiBase', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1',
});
assert.equal(baseUrl, 'http://127.0.0.1:6969');
});
test('deleteSession sends a DELETE request to the session endpoint', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
return new Response(null, { status: 200 });
}) as typeof globalThis.fetch;
try {
await apiClient.deleteSession(42);
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42`);
assert.equal(seenMethod, 'DELETE');
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession throws when the stats API delete request fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response('boom', {
status: 500,
statusText: 'Internal Server Error',
})) as typeof globalThis.fetch;
try {
await assert.rejects(() => apiClient.deleteSession(7), /Stats API error: 500 boom/);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -22,12 +22,27 @@ import type {
EpisodeDetailData, EpisodeDetailData,
} from '../types/stats'; } from '../types/stats';
export const BASE_URL = window.location.protocol === 'file:' type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
? 'http://127.0.0.1:5175'
: window.location.origin;
async function fetchJson<T>(path: string): Promise<T> { export function resolveStatsBaseUrl(location?: StatsLocationLike): string {
const res = await fetch(`${BASE_URL}${path}`); const resolvedLocation =
location ??
(typeof window === 'undefined'
? { protocol: 'file:', origin: 'null', search: '' }
: window.location);
const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim();
if (queryApiBase) {
return queryApiBase;
}
return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin;
}
export const BASE_URL = resolveStatsBaseUrl();
async function fetchResponse(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE_URL}${path}`, init);
if (!res.ok) { if (!res.ok) {
let body = ''; let body = '';
try { try {
@@ -39,6 +54,11 @@ async function fetchJson<T>(path: string): Promise<T> {
body ? `Stats API error: ${res.status} ${body}` : `Stats API error: ${res.status}`, body ? `Stats API error: ${res.status} ${body}` : `Stats API error: ${res.status}`,
); );
} }
return res;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetchResponse(path);
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
@@ -55,13 +75,7 @@ export const apiClient = {
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`), fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
getVocabulary: (limit = 100) => getVocabulary: (limit = 100) =>
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`), fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
getWordOccurrences: ( getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
headword: string,
word: string,
reading: string,
limit = 50,
offset = 0,
) =>
fetchJson<VocabularyOccurrenceEntry[]>( fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`, `/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
), ),
@@ -71,11 +85,9 @@ export const apiClient = {
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`, `/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
), ),
getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'), getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'),
getMediaDetail: (videoId: number) => getMediaDetail: (videoId: number) => fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'), getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
getAnimeDetail: (animeId: number) => getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
getAnimeWords: (animeId: number, limit = 50) => getAnimeWords: (animeId: number, limit = 50) =>
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`), fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) => getAnimeRollups: (animeId: number, limit = 90) =>
@@ -96,16 +108,54 @@ export const apiClient = {
getEpisodeDetail: (videoId: number) => getEpisodeDetail: (videoId: number) =>
fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`), fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => { setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
await fetch(`${BASE_URL}/api/stats/media/${videoId}/watched`, { await fetchResponse(`/api/stats/media/${videoId}/watched`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched }), body: JSON.stringify({ watched }),
}); });
}, },
ankiBrowse: async (noteId: number): Promise<void> => { deleteSession: async (sessionId: number): Promise<void> => {
await fetch(`${BASE_URL}/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' }); await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
}, },
ankiNotesInfo: async (noteIds: number[]): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => { deleteVideo: async (videoId: number): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
},
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
searchAnilist: (query: string) =>
fetchJson<
Array<{
id: number;
episodes: number | null;
season: string | null;
seasonYear: number | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}>
>(`/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
reassignAnimeAnilist: async (
animeId: number,
info: {
anilistId: number;
titleRomaji?: string | null;
titleEnglish?: string | null;
titleNative?: string | null;
episodesTotal?: number | null;
description?: string | null;
coverUrl?: string | null;
},
): Promise<void> => {
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info),
});
},
ankiBrowse: async (noteId: number): Promise<void> => {
await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
},
ankiNotesInfo: async (
noteIds: number[],
): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => {
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, { const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
test('confirmSessionDelete uses the shared session delete warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmSessionDelete(), true);
assert.deepEqual(calls, ['Delete this session and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return false;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmEpisodeDelete('Episode 4'), false);
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
} finally {
globalThis.confirm = originalConfirm;
}
});

View File

@@ -0,0 +1,7 @@
export function confirmSessionDelete(): boolean {
return globalThis.confirm('Delete this session and all associated data?');
}
export function confirmEpisodeDelete(title: string): boolean {
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
}

View File

@@ -1,5 +1,7 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import '@fontsource-variable/geist';
import '@fontsource-variable/geist-mono';
import { App } from './App'; import { App } from './App';
import './styles/globals.css'; import './styles/globals.css';

View File

@@ -27,15 +27,55 @@
--color-ctp-sapphire: #7dc4e4; --color-ctp-sapphire: #7dc4e4;
--color-ctp-maroon: #ee99a0; --color-ctp-maroon: #ee99a0;
--color-ctp-pink: #f5bde6; --color-ctp-pink: #f5bde6;
--font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
} }
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-family: var(--font-sans);
background-color: var(--color-ctp-base); background-color: var(--color-ctp-base);
color: var(--color-ctp-text); color: var(--color-ctp-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
body.overlay-mode { body.overlay-mode {
background-color: rgba(36, 39, 58, 0.85); background-color: rgba(36, 39, 58, 0.85);
} }
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-ctp-surface1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-ctp-surface2);
}
/* Tab content entrance animation */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeSlideIn 0.25s ease-out;
}

View File

@@ -58,6 +58,7 @@ export interface VocabularyEntry {
pos2: string | null; pos2: string | null;
pos3: string | null; pos3: string | null;
frequency: number; frequency: number;
frequencyRank: number | null;
firstSeen: number; firstSeen: number;
lastSeen: number; lastSeen: number;
} }
@@ -164,6 +165,7 @@ export interface AnimeDetailData {
titleRomaji: string | null; titleRomaji: string | null;
titleEnglish: string | null; titleEnglish: string | null;
titleNative: string | null; titleNative: string | null;
description: string | null;
totalSessions: number; totalSessions: number;
totalActiveMs: number; totalActiveMs: number;
totalCards: number; totalCards: number;