mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat: improve stats dashboard and annotation settings
This commit is contained in:
6
bun.lock
6
bun.lock
@@ -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=="],
|
||||||
|
|||||||
5
changes/2026-03-15-known-words-config-section.md
Normal file
5
changes/2026-03-15-known-words-config-section.md
Normal 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.
|
||||||
4
changes/2026-03-15-session-delete-from-sessions-tab.md
Normal file
4
changes/2026-03-15-session-delete-from-sessions-tab.md
Normal 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.
|
||||||
4
changes/2026-03-15-stats-overlay-port-fix.md
Normal file
4
changes/2026-03-15-stats-overlay-port-fix.md
Normal 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.
|
||||||
9
changes/2026-03-15-surface-frequency-fallback.md
Normal file
9
changes/2026-03-15-surface-frequency-fallback.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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
141
scripts/update-frequency.ts
Normal 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();
|
||||||
@@ -56,7 +56,7 @@ function createIntegrationTestContext(
|
|||||||
|
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
nPlusOne: {
|
knownWords: {
|
||||||
highlightEnabled: options.highlightEnabled ?? true,
|
highlightEnabled: options.highlightEnabled ?? true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
'猫です',
|
'猫です',
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return collapsedRankByTerm;
|
function getYomitanFrequencyRank(
|
||||||
|
token: MergedToken,
|
||||||
|
candidateText: string,
|
||||||
|
matchMode: FrequencyDictionaryMatchMode,
|
||||||
|
frequencyIndex: YomitanFrequencyIndex,
|
||||||
|
): number | null {
|
||||||
|
const normalizedCandidateText = candidateText.trim();
|
||||||
|
if (!normalizedCandidateText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
12
src/types.ts
12
src/types.ts
@@ -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: {
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
143
stats/src/components/anime/AnilistSelector.tsx
Normal file
143
stats/src/components/anime/AnilistSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
|||||||
>
|
>
|
||||||
← Back to Anime
|
← 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
139
stats/src/components/vocabulary/FrequencyRankTable.tsx
Normal file
139
stats/src/components/vocabulary/FrequencyRankTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
67
stats/src/lib/api-client.test.ts
Normal file
67
stats/src/lib/api-client.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
|||||||
35
stats/src/lib/delete-confirm.test.ts
Normal file
35
stats/src/lib/delete-confirm.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
7
stats/src/lib/delete-confirm.ts
Normal file
7
stats/src/lib/delete-confirm.ts
Normal 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?`);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user