Compare commits

..

2 Commits

Author SHA1 Message Date
sudacode 08c6807cb1 fix(launcher): normalize rofi prompt spacing
- Add formatRofiPrompt to trim trailing whitespace and append a single space, keeping the prompt from running into the input field
- Apply it across all rofi -p usages in picker.ts and history-command.ts
- Add tests for formatRofiPrompt edge cases (empty/whitespace-only prompts)
2026-07-26 00:13:54 -07:00
sudacode 18c6410f24 feat(launcher): add previous episode option to history entry menu
- Extract buildHistoryEntryActions to build the series action menu, now including a previous-episode option alongside replay/next/browse/quit
- Update docs and changelog entry to describe the new option
- Add tests covering previous/replay/next ordering and omission when last watched file is missing
2026-07-25 23:35:44 -07:00
41 changed files with 165 additions and 1382 deletions
@@ -1,4 +0,0 @@
type: added
area: overlay
- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session.
+1
View File
@@ -2,3 +2,4 @@ type: added
area: launcher area: launcher
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories. - After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
-8
View File
@@ -433,12 +433,6 @@
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"knownWordMaturityColors": {
"new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.
"learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.
"young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.
"mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.
}, // Known word maturity colors setting.
"jlptColors": { "jlptColors": {
"N1": "#ed8796", // N1 setting. "N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", // N2 setting. "N2": "#f5a97f", // N2 setting.
@@ -575,8 +569,6 @@
}, // Media setting. }, // Media setting.
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false
"matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21).
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
-1
View File
@@ -407,7 +407,6 @@ See `config.example.jsonc` for detailed configuration options.
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) | | `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) | | `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) | | `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. | | `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
+1
View File
@@ -73,6 +73,7 @@ subminer -R -H # rofi history browser
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu: The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
- **Previous episode**: plays the episode before the last watched one and continues into the previous season directory when the season starts
- **Replay last watched**: replays the most recently watched episode - **Replay last watched**: replays the most recently watched episode
- **Next episode**: plays the episode after the last watched one and continues into the next season directory when the season ends - **Next episode**: plays the episode after the last watched one and continues into the next season directory when the season ends
- **Browse episodes**: lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu appears first - **Browse episodes**: lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu appears first
-8
View File
@@ -433,12 +433,6 @@
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"knownWordMaturityColors": {
"new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.
"learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.
"young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.
"mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.
}, // Known word maturity colors setting.
"jlptColors": { "jlptColors": {
"N1": "#ed8796", // N1 setting. "N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", // N2 setting. "N2": "#f5a97f", // N2 setting.
@@ -575,8 +569,6 @@
}, // Media setting. }, // Media setting.
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false
"matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21).
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
+1 -26
View File
@@ -43,30 +43,6 @@ Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only f
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.
::: :::
## Known-Word Maturity Highlighting
Instead of one color for every known word, maturity highlighting tints each known token by the review state of its Anki cards (like asbplayer), giving an at-a-glance sense of how much of a line is solidly learned.
**How it works:**
1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`, `is:new`) - no extra card data is downloaded.
2. Each note gets the tier of its **most mature** card: `mature` (interval ≥ threshold), `young` (in review below the threshold), `learning` (in (re)learning), or `new` (never reviewed).
3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting.
4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color.
**Key settings:**
| Option | Default | Description |
| -------------------------------------------- | --------- | ---------------------------------------------------------- |
| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) |
| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature |
| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards |
| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for (re)learning cards |
| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards |
| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards |
Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched.
## Character-Name Highlighting ## Character-Name Highlighting
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail. Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail.
@@ -155,7 +131,6 @@ All colors are customizable via the `subtitleStyle.jlptColors` object.
These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting: These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting:
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`) - `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
- `ankiConnect.knownWords.maturityEnabled` (`On` / `Off`)
- `ankiConnect.knownWords.matchMode` - `ankiConnect.knownWords.matchMode`
- `ankiConnect.nPlusOne.enabled` (`On` / `Off`) - `ankiConnect.nPlusOne.enabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`) - `subtitleStyle.enableJlpt` (`On` / `Off`)
@@ -171,6 +146,6 @@ When multiple annotations apply to the same token, the visual priority is:
1. **Character-name match** (highest) - dictionary-driven character-name token styling; it clears the token's N+1, frequency, and JLPT annotations 1. **Character-name match** (highest) - dictionary-driven character-name token styling; it clears the token's N+1, frequency, and JLPT annotations
2. **N+1 target** - the single unknown word in an N+1 sentence 2. **N+1 target** - the single unknown word in an N+1 sentence
3. **Known-word color** - already-learned token tint (per-tier maturity colors when `maturityEnabled` is on) 3. **Known-word color** - already-learned token tint
4. **Frequency highlight** - common-word coloring (not applied when a higher layer already matched) 4. **Frequency highlight** - common-word coloring (not applied when a higher layer already matched)
5. **JLPT underline** - level-based underline (stacks with N+1/known/frequency since it uses underline rather than text color, but not with a character-name match) 5. **JLPT underline** - level-based underline (stacks with N+1/known/frequency since it uses underline rather than text color, but not with a character-name match)
+47 -11
View File
@@ -7,6 +7,7 @@ import {
collectVideos, collectVideos,
findRofiTheme, findRofiTheme,
formatPickerLaunchError, formatPickerLaunchError,
formatRofiPrompt,
showFzfMenu, showFzfMenu,
showRofiMenu, showRofiMenu,
} from '../picker.js'; } from '../picker.js';
@@ -67,6 +68,34 @@ export function buildHistorySessionActions(
return actions; return actions;
} }
export function buildHistoryEntryActions(
lastWatchedPath: string | null,
previousEpisodePath: string | null,
nextEpisodePath: string | null,
): HistorySessionMenuAction[] {
const actions: HistorySessionMenuAction[] = [];
if (previousEpisodePath) {
actions.push({
kind: 'previous',
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
});
}
if (lastWatchedPath) {
actions.push({
kind: 'replay',
label: `Replay last watched: ${path.basename(lastWatchedPath)}`,
});
}
if (nextEpisodePath) {
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisodePath)}` });
}
actions.push(
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
);
return actions;
}
interface HistoryPlaybackLoopDeps { interface HistoryPlaybackLoopDeps {
play: (videoPath: string) => Promise<void>; play: (videoPath: string) => Promise<void>;
pickPostPlaybackAction: (input: { pickPostPlaybackAction: (input: {
@@ -171,7 +200,16 @@ function showRofiIndexMenu(
themePath: string | null, themePath: string | null,
icons: Array<string | null> = [], icons: Array<string | null> = [],
): number { ): number {
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt]; const rofiArgs = [
'-dmenu',
'-i',
'-matching',
'fuzzy',
'-format',
'i',
'-p',
formatRofiPrompt(prompt),
];
const hasIcons = icons.some(Boolean); const hasIcons = icons.some(Boolean);
if (hasIcons) rofiArgs.push('-show-icons'); if (hasIcons) rofiArgs.push('-show-icons');
if (themePath) { if (themePath) {
@@ -332,17 +370,14 @@ export async function runHistoryCommand(
const lastPath = path.resolve(entry.lastWatched.sourcePath); const lastPath = path.resolve(entry.lastWatched.sourcePath);
const lastExists = fs.existsSync(lastPath); const lastExists = fs.existsSync(lastPath);
const previousEpisode = findPreviousEpisode(lastPath);
const nextEpisode = findNextEpisode(lastPath); const nextEpisode = findNextEpisode(lastPath);
const actions: HistorySessionMenuAction[] = []; const actions = buildHistoryEntryActions(
if (lastExists) { lastExists ? lastPath : null,
actions.push({ kind: 'replay', label: `Replay last watched: ${path.basename(lastPath)}` }); previousEpisode,
} nextEpisode,
if (nextEpisode) { );
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisode)}` });
}
actions.push({ kind: 'browse', label: 'Browse episodes' });
actions.push({ kind: 'quit', label: 'Quit SubMiner' });
const entryIcon = seriesIcons[seriesIdx] ?? null; const entryIcon = seriesIcons[seriesIdx] ?? null;
const actionIdx = pickIndex( const actionIdx = pickIndex(
@@ -357,13 +392,14 @@ export async function runHistoryCommand(
switch (actions[actionIdx]!.kind) { switch (actions[actionIdx]!.kind) {
case 'replay': case 'replay':
return { entry, videoPath: lastPath, themePath, entryIcon }; return { entry, videoPath: lastPath, themePath, entryIcon };
case 'previous':
return previousEpisode ? { entry, videoPath: previousEpisode, themePath, entryIcon } : null;
case 'next': case 'next':
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null; return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
case 'browse': { case 'browse': {
const videoPath = browseEpisodes(entry, context, themePath); const videoPath = browseEpisodes(entry, context, themePath);
return videoPath ? { entry, videoPath, themePath, entryIcon } : null; return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
} }
case 'previous':
case 'quit': case 'quit':
return null; return null;
} }
+36 -1
View File
@@ -1,7 +1,11 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import path from 'node:path'; import path from 'node:path';
import { buildHistorySessionActions, runHistoryPlaybackLoop } from './history-command.js'; import {
buildHistoryEntryActions,
buildHistorySessionActions,
runHistoryPlaybackLoop,
} from './history-command.js';
import type { HistorySeriesEntry } from '../history.js'; import type { HistorySeriesEntry } from '../history.js';
type HistoryLoop = ( type HistoryLoop = (
@@ -186,6 +190,37 @@ test('history show menu omits previous and next when the just-played episode has
]); ]);
}); });
test('history entry menu offers previous, replay, and next before playback starts', () => {
assert.equal(
typeof buildHistoryEntryActions,
'function',
'history entry actions not implemented',
);
assert.deepEqual(
buildHistoryEntryActions(
'/shows/test-show/episode-03.mkv',
'/shows/test-show/episode-02.mkv',
'/shows/test-show/episode-04.mkv',
),
[
{ kind: 'previous', label: 'Previous episode: episode-02.mkv' },
{ kind: 'replay', label: 'Replay last watched: episode-03.mkv' },
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
],
);
});
test('history entry menu omits replay when the last watched file is gone', () => {
assert.deepEqual(buildHistoryEntryActions(null, null, '/shows/test-show/episode-04.mkv'), [
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
]);
});
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => { test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
assert.equal( assert.equal(
typeof runHistoryPlaybackLoop, typeof runHistoryPlaybackLoop,
+16 -1
View File
@@ -3,7 +3,22 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { findRofiTheme } from './picker'; import { findRofiTheme, formatRofiPrompt } from './picker';
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
test('formatRofiPrompt appends a single trailing space', () => {
assert.equal(formatRofiPrompt('Select Video'), 'Select Video ');
});
test('formatRofiPrompt collapses existing trailing whitespace to one space', () => {
assert.equal(formatRofiPrompt('Watch History '), 'Watch History ');
});
test('formatRofiPrompt leaves an empty prompt empty', () => {
assert.equal(formatRofiPrompt(''), '');
assert.equal(formatRofiPrompt(' '), '');
});
// ── findRofiTheme: Linux packaged path discovery ────────────────────────────── // ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
+13 -4
View File
@@ -17,13 +17,22 @@ export function escapeShellSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`; return `'${value.replace(/'/g, `'\\''`)}'`;
} }
/**
* Rofi renders the prompt flush against the input field, so keep exactly one
* trailing space to separate them.
*/
export function formatRofiPrompt(prompt: string): string {
const trimmed = prompt.trimEnd();
return trimmed ? `${trimmed} ` : '';
}
export function showRofiFlatMenu( export function showRofiFlatMenu(
items: string[], items: string[],
prompt: string, prompt: string,
initialQuery = '', initialQuery = '',
themePath: string | null = null, themePath: string | null = null,
): string { ): string {
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt]; const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', formatRofiPrompt(prompt)];
if (themePath) { if (themePath) {
args.push('-theme', themePath); args.push('-theme', themePath);
} else { } else {
@@ -110,7 +119,7 @@ export async function promptOptionalJellyfinSearch(
themePath: string | null = null, themePath: string | null = null,
): Promise<string> { ): Promise<string> {
if (useRofi && commandExists('rofi')) { if (useRofi && commandExists('rofi')) {
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)']; const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
if (themePath) { if (themePath) {
rofiArgs.push('-theme', themePath); rofiArgs.push('-theme', themePath);
} else { } else {
@@ -157,7 +166,7 @@ function showRofiIconMenu(
themePath: string | null = null, themePath: string | null = null,
): number { ): number {
if (entries.length === 0) return -1; if (entries.length === 0) return -1;
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt]; const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', formatRofiPrompt(prompt)];
if (initialQuery) rofiArgs.push('-filter', initialQuery); if (initialQuery) rofiArgs.push('-filter', initialQuery);
if (themePath) { if (themePath) {
rofiArgs.push('-theme', themePath); rofiArgs.push('-theme', themePath);
@@ -391,7 +400,7 @@ export function showRofiMenu(
'-dmenu', '-dmenu',
'-i', '-i',
'-p', '-p',
'Select Video ', formatRofiPrompt('Select Video'),
'-show-icons', '-show-icons',
'-theme-str', '-theme-str',
'configuration { font: "Noto Sans CJK JP Regular 8";}', 'configuration { font: "Noto Sans CJK JP Regular 8";}',
-9
View File
@@ -28,7 +28,6 @@ import {
NotificationOptions, NotificationOptions,
} from './types/anki'; } from './types/anki';
import { AiConfig } from './types/integrations'; import { AiConfig } from './types/integrations';
import type { KnownWordMaturityTier } from './types/subtitle';
import { MpvClient } from './types/runtime'; import { MpvClient } from './types/runtime';
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification'; import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
import type { NotificationType, OverlayNotificationPayload } from './types/notification'; import type { NotificationType, OverlayNotificationPayload } from './types/notification';
@@ -733,14 +732,6 @@ export class AnkiIntegration {
return this.knownWordCache.isKnownWord(text, reading, options); return this.knownWordCache.isKnownWord(text, reading, options);
} }
getKnownWordTier(
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
): KnownWordMaturityTier | null {
return this.knownWordCache.getKnownWordTier(text, reading, options);
}
getKnownWordMatchMode(): NPlusOneMatchMode { getKnownWordMatchMode(): NPlusOneMatchMode {
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode; return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
} }
@@ -1,374 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { AnkiConnectConfig } from '../types/anki';
import { setLogLevel } from '../logger';
import { KnownWordCacheManager, getKnownWordCacheLifecycleConfig } from './known-word-cache';
interface HarnessNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
function createMaturityHarness(config: AnkiConnectConfig): {
manager: KnownWordCacheManager;
calls: { findNotes: number; notesInfo: number; queries: string[] };
statePath: string;
clientState: {
findNotesResult: number[];
notesInfoResult: HarnessNoteInfo[];
findNotesByQuery: Map<string, number[]>;
failedQueries: Set<string>;
};
createSiblingManager: () => KnownWordCacheManager;
cleanup: () => void;
} {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-maturity-'));
const statePath = path.join(stateDir, 'known-words-cache.json');
const calls = { findNotes: 0, notesInfo: 0, queries: [] as string[] };
const clientState = {
findNotesResult: [] as number[],
notesInfoResult: [] as HarnessNoteInfo[],
findNotesByQuery: new Map<string, number[]>(),
failedQueries: new Set<string>(),
};
const deps = {
client: {
findNotes: async (query: string) => {
calls.findNotes += 1;
calls.queries.push(query);
if (clientState.failedQueries.has(query)) {
throw new Error(`Anki unavailable for query: ${query}`);
}
if (clientState.findNotesByQuery.has(query)) {
return clientState.findNotesByQuery.get(query) ?? [];
}
return clientState.findNotesResult;
},
notesInfo: async (noteIds: number[]) => {
calls.notesInfo += 1;
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
},
},
getConfig: () => config,
knownWordCacheStatePath: statePath,
showStatusNotification: () => undefined,
};
return {
manager: new KnownWordCacheManager(deps),
calls,
statePath,
clientState,
createSiblingManager: () => new KnownWordCacheManager(deps),
cleanup: () => {
fs.rmSync(stateDir, { recursive: true, force: true });
},
};
}
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
return {
deck: 'Mining',
fields: { word: 'Word' },
knownWords: {
highlightEnabled: true,
maturityEnabled: true,
refreshMinutes: 60,
},
...overrides,
};
}
test('lifecycle config key is unchanged when maturity is disabled', () => {
const disabled: AnkiConnectConfig = {
knownWords: { highlightEnabled: true, refreshMinutes: 60 },
};
// Upgrading users keep their persisted cache: the key must not gain fields
// while maturity is off.
assert.equal(
getKnownWordCacheLifecycleConfig(disabled),
'{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
);
const enabled: AnkiConnectConfig = {
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
};
assert.equal(
getKnownWordCacheLifecycleConfig(enabled),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
);
const customThreshold: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
maturityEnabled: true,
matureThresholdDays: 30,
refreshMinutes: 60,
},
};
assert.equal(
getKnownWordCacheLifecycleConfig(customThreshold),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30}',
);
});
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]);
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
{ noteId: 2, fields: { Word: { value: '犬' } } },
{ noteId: 3, fields: { Word: { value: '鳥' } } },
{ noteId: 4, fields: { Word: { value: '魚' } } },
];
await manager.refresh(true);
assert.equal(calls.findNotes, 4);
assert.equal(manager.getKnownWordTier('猫'), 'mature');
assert.equal(manager.getKnownWordTier('犬'), 'young');
assert.equal(manager.getKnownWordTier('鳥'), 'learning');
assert.equal(manager.getKnownWordTier('魚'), 'new');
assert.equal(manager.getKnownWordTier('馬'), null);
// Boolean matching still works alongside tiers.
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('魚'), true);
} finally {
cleanup();
}
});
test('a note with cards in several tiers counts as its most mature card', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
await manager.refresh(true);
assert.equal(manager.getKnownWordTier('猫'), 'mature');
} finally {
cleanup();
}
});
test('a word matched by several notes takes the most mature note tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', []);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
{ noteId: 2, fields: { Word: { value: '猫' } } },
];
await manager.refresh(true);
assert.equal(manager.getKnownWordTier('猫'), 'young');
} finally {
cleanup();
}
});
test('tiers are reading-aware for words with several readings', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
];
await manager.refresh(true);
assert.equal(manager.getKnownWordTier('床', 'とこ'), 'mature');
assert.equal(manager.getKnownWordTier('床', 'ゆか'), 'learning');
// No reading given: fail-open across readings, most mature wins.
assert.equal(manager.getKnownWordTier('床'), 'mature');
// Unknown reading for a reading-locked word: no match, no tier.
assert.equal(manager.getKnownWordTier('床', 'しょう'), null);
} finally {
cleanup();
}
});
test('reading-only fallback resolves tiers unless opted out', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', []);
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
];
await manager.refresh(true);
assert.equal(manager.getKnownWordTier('けいこく'), 'mature');
assert.equal(
manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }),
null,
);
} finally {
cleanup();
}
});
test('getKnownWordTier returns null and skips tier queries when maturity is disabled', async () => {
const config = maturityConfig();
config.knownWords = { ...config.knownWords, maturityEnabled: false };
const { manager, calls, clientState, cleanup } = createMaturityHarness(config);
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
await manager.refresh(true);
assert.equal(calls.findNotes, 1);
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.getKnownWordTier('猫'), null);
} finally {
cleanup();
}
});
test('refresh preserves known-word cache when maturity lookup fails', async () => {
const { manager, statePath, clientState, cleanup } = createMaturityHarness(maturityConfig());
const originalInfo = console.info;
const infoLogs: string[] = [];
setLogLevel('info');
try {
console.info = (...args: unknown[]) => {
infoLogs.push(args.map((value) => String(value)).join(' '));
};
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.failedQueries.add('deck:"Mining" prop:ivl>=21');
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.getKnownWordTier('猫'), null);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
tiers: Record<string, string>;
};
assert.equal(persisted.version, 4);
assert.deepEqual(persisted.tiers, {});
assert.match(infoLogs.join('\n'), /maturityTiers=fetch-failed/);
} finally {
console.info = originalInfo;
setLogLevel(undefined);
cleanup();
}
});
test('tiers persist to v4 state and reload without refetching', async () => {
const { manager, calls, statePath, clientState, createSiblingManager, cleanup } =
createMaturityHarness(maturityConfig());
const originalDateNow = Date.now;
try {
Date.now = () => 120_000;
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
{ noteId: 2, fields: { Word: { value: '犬' } } },
];
await manager.refresh(true);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
tiers: Record<string, string>;
};
assert.equal(persisted.version, 4);
assert.deepEqual(persisted.tiers, { '1': 'mature', '2': 'learning' });
const callsBeforeReload = calls.findNotes;
const reloaded = createSiblingManager();
reloaded.startLifecycle();
try {
assert.equal(reloaded.getKnownWordTier('猫'), 'mature');
assert.equal(reloaded.getKnownWordTier('犬'), 'learning');
assert.equal(calls.findNotes, callsBeforeReload);
} finally {
reloaded.stopLifecycle();
}
} finally {
Date.now = originalDateNow;
cleanup();
}
});
test('appendFromNoteInfo marks freshly mined notes as new tier', async () => {
const { manager, cleanup } = createMaturityHarness(maturityConfig());
try {
manager.appendFromNoteInfo({
noteId: 7,
fields: { Word: { value: '猫' } },
});
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.getKnownWordTier('猫'), 'new');
} finally {
cleanup();
}
});
test('appendFromNoteInfo preserves an existing maturity tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [7, 8]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [7]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [8]);
clientState.notesInfoResult = [
{ noteId: 7, fields: { Word: { value: '猫' } } },
{ noteId: 8, fields: { Word: { value: '犬' } } },
];
await manager.refresh(true);
manager.appendFromNoteInfo({
noteId: 7,
fields: { Word: { value: '子猫' } },
});
manager.appendFromNoteInfo({
noteId: 8,
fields: { Word: { value: '子犬' } },
});
assert.equal(manager.getKnownWordTier('子猫'), 'mature');
assert.equal(manager.getKnownWordTier('子犬'), 'learning');
} finally {
cleanup();
}
});
@@ -314,7 +314,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
version: number; version: number;
notes?: Record<string, Array<{ word: string; reading: string | null }>>; notes?: Record<string, Array<{ word: string; reading: string | null }>>;
}; };
assert.equal(persisted.version, 4); assert.equal(persisted.version, 3);
assert.deepEqual(persisted.notes, { assert.deepEqual(persisted.notes, {
'1': [{ word: '鳥', reading: null }], '1': [{ word: '鳥', reading: null }],
}); });
+44 -223
View File
@@ -4,16 +4,7 @@ import path from 'path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getConfiguredWordFieldName } from '../anki-field-config'; import { getConfiguredWordFieldName } from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki'; import { AnkiConnectConfig } from '../types/anki';
import type { KnownWordMaturityTier } from '../types/subtitle';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import {
classifyKnownWordNoteTier,
fetchKnownWordMaturityTierSets,
getKnownWordMaturityEnabled,
getMatureIntervalThresholdDays,
maxKnownWordMaturityTier,
sanitizeKnownWordMaturityTier,
} from './known-word-maturity';
import { import {
DEFAULT_KNOWN_WORD_READING_FIELDS, DEFAULT_KNOWN_WORD_READING_FIELDS,
KnownWordEntry, KnownWordEntry,
@@ -71,17 +62,11 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
} }
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string { export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
const payload: Record<string, unknown> = { return JSON.stringify({
refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config), refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config),
scope: getKnownWordCacheScopeForConfig(config), scope: getKnownWordCacheScopeForConfig(config),
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '', fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
}; });
// The maturity field is only added while enabled so persisted caches from
// before the feature existed (or with it off) keep their identity.
if (getKnownWordMaturityEnabled(config)) {
payload.maturity = getMatureIntervalThresholdDays(config);
}
return JSON.stringify(payload);
} }
export interface KnownWordCacheNoteInfo { export interface KnownWordCacheNoteInfo {
@@ -111,19 +96,7 @@ interface KnownWordCacheStateV3 {
readonly notes: Record<string, KnownWordEntry[]>; readonly notes: Record<string, KnownWordEntry[]>;
} }
interface KnownWordCacheStateV4 { type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
readonly version: 4;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
readonly tiers: Record<string, KnownWordMaturityTier>;
}
type KnownWordCacheState =
| KnownWordCacheStateV1
| KnownWordCacheStateV2
| KnownWordCacheStateV3
| KnownWordCacheStateV4;
const NO_READING_KEY = ''; const NO_READING_KEY = '';
@@ -152,13 +125,12 @@ type KnownWordQueryScope = {
export class KnownWordCacheManager { export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0; private knownWordsLastRefreshedAtMs = 0;
private knownWordsStateKey = ''; private knownWordsStateKey = '';
// word → (hiragana reading | NO_READING_KEY → note ids). NO_READING_KEY // word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
// entries fail open: the word matches regardless of the token's reading. // entries fail open: the word matches regardless of the token's reading.
private wordReadingNoteIds = new Map<string, Map<string, Set<number>>>(); private wordReadingCounts = new Map<string, Map<string, number>>();
// hiragana reading → note ids, so kana tokens still match by reading alone. // hiragana reading → note count, so kana tokens still match by reading alone.
private readingNoteIds = new Map<string, Set<number>>(); private readingCounts = new Map<string, number>();
private noteEntriesById = new Map<number, KnownWordEntry[]>(); private noteEntriesById = new Map<number, KnownWordEntry[]>();
private noteTierById = new Map<number, KnownWordMaturityTier>();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null; private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null; private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
private isRefreshingKnownWords = false; private isRefreshingKnownWords = false;
@@ -184,7 +156,7 @@ export class KnownWordCacheManager {
return false; return false;
} }
const knownReadings = this.wordReadingNoteIds.get(normalized); const knownReadings = this.wordReadingCounts.get(normalized);
if (knownReadings && knownReadings.size > 0) { if (knownReadings && knownReadings.size > 0) {
const normalizedReading = const normalizedReading =
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : ''; typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
@@ -196,7 +168,7 @@ export class KnownWordCacheManager {
} }
// Callers that look up a kanji token's reading (not subtitle text) must // Callers that look up a kanji token's reading (not subtitle text) must
// opt out of the reading-only fallback: readingNoteIds holds readings of // opt out of the reading-only fallback: readingCounts holds readings of
// every note including kanji words, so 渓谷's けいこく would match a // every note including kanji words, so 渓谷's けいこく would match a
// mined 警告/けいこく. // mined 警告/けいこく.
if (options?.allowReadingOnlyMatch === false) { if (options?.allowReadingOnlyMatch === false) {
@@ -210,73 +182,7 @@ export class KnownWordCacheManager {
if ([...hiragana].length === 1) { if ([...hiragana].length === 1) {
return false; return false;
} }
return this.readingNoteIds.has(hiragana); return this.readingCounts.has(hiragana);
}
// Maturity tier for a matching known word, following the exact matching
// rules of isKnownWord. A match with no tier data (tier fetch failed or
// pre-v4 cache) returns null so rendering falls back to the single
// known-word color.
getKnownWordTier(
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
): KnownWordMaturityTier | null {
if (!getKnownWordMaturityEnabled(this.deps.getConfig())) {
return null;
}
const normalized = this.normalizeKnownWordForLookup(text);
if (normalized.length === 0) {
return null;
}
const knownReadings = this.wordReadingNoteIds.get(normalized);
if (knownReadings && knownReadings.size > 0) {
const normalizedReading =
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
let tier: KnownWordMaturityTier | null = null;
if (normalizedReading.length === 0) {
for (const noteIds of knownReadings.values()) {
tier = this.maxTierForNotes(tier, noteIds);
}
return tier;
}
const noReadingNotes = knownReadings.get(NO_READING_KEY);
if (noReadingNotes) {
tier = this.maxTierForNotes(tier, noReadingNotes);
}
const exactReadingNotes = knownReadings.get(normalizedReading);
if (exactReadingNotes) {
tier = this.maxTierForNotes(tier, exactReadingNotes);
}
return tier;
}
if (options?.allowReadingOnlyMatch === false) {
return null;
}
const hiragana = convertKatakanaToHiragana(normalized);
if ([...hiragana].length === 1) {
return null;
}
const readingNotes = this.readingNoteIds.get(hiragana);
return readingNotes ? this.maxTierForNotes(null, readingNotes) : null;
}
private maxTierForNotes(
current: KnownWordMaturityTier | null,
noteIds: ReadonlySet<number>,
): KnownWordMaturityTier | null {
let tier = current;
for (const noteId of noteIds) {
tier = maxKnownWordMaturityTier(tier, this.noteTierById.get(noteId) ?? null);
if (tier === 'mature') {
break;
}
}
return tier;
} }
refresh(force = false): Promise<void> { refresh(force = false): Promise<void> {
@@ -323,7 +229,7 @@ export class KnownWordCacheManager {
let didMutateCache = false; let didMutateCache = false;
const currentStateKey = this.getKnownWordCacheStateKey(); const currentStateKey = this.getKnownWordCacheStateKey();
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) { if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
didMutateCache = this.wordReadingNoteIds.size > 0 || this.noteEntriesById.size > 0; didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
this.clearKnownWordCacheState(); this.clearKnownWordCacheState();
} }
if (!this.knownWordsStateKey) { if (!this.knownWordsStateKey) {
@@ -341,15 +247,6 @@ export class KnownWordCacheManager {
return didMutateCache; return didMutateCache;
} }
// A just-mined card has never been reviewed.
if (
this.isMaturityTrackingEnabled() &&
this.noteEntriesById.has(noteInfo.noteId) &&
!this.noteTierById.has(noteInfo.noteId)
) {
this.noteTierById.set(noteInfo.noteId, 'new');
}
if (this.knownWordsLastRefreshedAtMs <= 0) { if (this.knownWordsLastRefreshedAtMs <= 0) {
this.knownWordsLastRefreshedAtMs = Date.now(); this.knownWordsLastRefreshedAtMs = Date.now();
} }
@@ -393,21 +290,6 @@ export class KnownWordCacheManager {
this.isRefreshingKnownWords = true; this.isRefreshingKnownWords = true;
try { try {
const noteFieldsById = await this.fetchKnownWordNoteFieldsById(); const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
const maturityTrackingEnabled = this.isMaturityTrackingEnabled();
let maturityFetchFailed = false;
let tierSets = null;
if (maturityTrackingEnabled) {
try {
tierSets = await fetchKnownWordMaturityTierSets(
(query, options) => this.deps.client.findNotes(query, options),
this.getKnownWordQueryScopes().map((scope) => scope.query),
getMatureIntervalThresholdDays(this.deps.getConfig()),
);
} catch (error) {
maturityFetchFailed = true;
log.warn('Failed to fetch known-word maturity tiers:', (error as Error).message);
}
}
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b); const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
if (this.noteEntriesById.size === 0) { if (this.noteEntriesById.size === 0) {
@@ -434,25 +316,13 @@ export class KnownWordCacheManager {
} }
} }
this.noteTierById = new Map();
if (tierSets) {
for (const noteId of currentNoteIds) {
this.noteTierById.set(noteId, classifyKnownWordNoteTier(noteId, tierSets));
}
}
this.knownWordsLastRefreshedAtMs = Date.now(); this.knownWordsLastRefreshedAtMs = Date.now();
this.knownWordsStateKey = frozenStateKey; this.knownWordsStateKey = frozenStateKey;
this.persistKnownWordCacheState(); this.persistKnownWordCacheState();
log.info( log.info(
'Known-word cache refreshed', 'Known-word cache refreshed',
`noteCount=${currentNoteIds.length}`, `noteCount=${currentNoteIds.length}`,
`wordCount=${this.wordReadingNoteIds.size}`, `wordCount=${this.wordReadingCounts.size}`,
tierSets
? `maturityTiers=${this.noteTierById.size}`
: maturityFetchFailed
? 'maturityTiers=fetch-failed'
: 'maturityTiers=off',
); );
} catch (error) { } catch (error) {
log.warn('Failed to refresh known-word cache:', (error as Error).message); log.warn('Failed to refresh known-word cache:', (error as Error).message);
@@ -467,10 +337,6 @@ export class KnownWordCacheManager {
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true; return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
} }
private isMaturityTrackingEnabled(): boolean {
return getKnownWordMaturityEnabled(this.deps.getConfig());
}
private shouldAddMinedWordsImmediately(): boolean { private shouldAddMinedWordsImmediately(): boolean {
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false; return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
} }
@@ -727,13 +593,12 @@ export class KnownWordCacheManager {
return false; return false;
} }
this.removeEntriesFromIndexes(noteId, previousEntries); this.removeEntriesFromCounts(previousEntries);
if (normalizedEntries.length > 0) { if (normalizedEntries.length > 0) {
this.noteEntriesById.set(noteId, normalizedEntries); this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToIndexes(noteId, normalizedEntries); this.addEntriesToCounts(normalizedEntries);
} else { } else {
this.noteEntriesById.delete(noteId); this.noteEntriesById.delete(noteId);
this.noteTierById.delete(noteId);
} }
return true; return true;
} }
@@ -744,68 +609,54 @@ export class KnownWordCacheManager {
return; return;
} }
this.noteEntriesById.delete(noteId); this.noteEntriesById.delete(noteId);
this.noteTierById.delete(noteId); this.removeEntriesFromCounts(previousEntries);
this.removeEntriesFromIndexes(noteId, previousEntries);
} }
private addEntriesToIndexes(noteId: number, entries: KnownWordEntry[]): void { private addEntriesToCounts(entries: KnownWordEntry[]): void {
for (const entry of entries) { for (const entry of entries) {
const readingKey = entry.reading ?? NO_READING_KEY; const readingKey = entry.reading ?? NO_READING_KEY;
let readings = this.wordReadingNoteIds.get(entry.word); let readings = this.wordReadingCounts.get(entry.word);
if (!readings) { if (!readings) {
readings = new Map(); readings = new Map();
this.wordReadingNoteIds.set(entry.word, readings); this.wordReadingCounts.set(entry.word, readings);
} }
let noteIds = readings.get(readingKey); readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
if (!noteIds) {
noteIds = new Set();
readings.set(readingKey, noteIds);
}
noteIds.add(noteId);
if (entry.reading) { if (entry.reading) {
let readingNotes = this.readingNoteIds.get(entry.reading); this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
if (!readingNotes) {
readingNotes = new Set();
this.readingNoteIds.set(entry.reading, readingNotes);
}
readingNotes.add(noteId);
} }
} }
} }
private removeEntriesFromIndexes(noteId: number, entries: KnownWordEntry[]): void { private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
for (const entry of entries) { for (const entry of entries) {
const readingKey = entry.reading ?? NO_READING_KEY; const readingKey = entry.reading ?? NO_READING_KEY;
const readings = this.wordReadingNoteIds.get(entry.word); const readings = this.wordReadingCounts.get(entry.word);
if (readings) { if (readings) {
const noteIds = readings.get(readingKey); const nextCount = (readings.get(readingKey) ?? 0) - 1;
if (noteIds) { if (nextCount > 0) {
noteIds.delete(noteId); readings.set(readingKey, nextCount);
if (noteIds.size === 0) { } else {
readings.delete(readingKey); readings.delete(readingKey);
if (readings.size === 0) { if (readings.size === 0) {
this.wordReadingNoteIds.delete(entry.word); this.wordReadingCounts.delete(entry.word);
}
} }
} }
} }
if (entry.reading) { if (entry.reading) {
const readingNotes = this.readingNoteIds.get(entry.reading); const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
if (readingNotes) { if (nextReadingCount > 0) {
readingNotes.delete(noteId); this.readingCounts.set(entry.reading, nextReadingCount);
if (readingNotes.size === 0) { } else {
this.readingNoteIds.delete(entry.reading); this.readingCounts.delete(entry.reading);
}
} }
} }
} }
} }
private clearInMemoryState(): void { private clearInMemoryState(): void {
this.wordReadingNoteIds = new Map(); this.wordReadingCounts = new Map();
this.readingNoteIds = new Map(); this.readingCounts = new Map();
this.noteEntriesById = new Map(); this.noteEntriesById = new Map();
this.noteTierById = new Map();
this.knownWordsLastRefreshedAtMs = 0; this.knownWordsLastRefreshedAtMs = 0;
} }
@@ -838,7 +689,7 @@ export class KnownWordCacheManager {
} }
this.clearInMemoryState(); this.clearInMemoryState();
if (parsed.version === 3 || parsed.version === 4) { if (parsed.version === 3) {
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) { for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10); const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) { if (!Number.isInteger(noteId) || noteId <= 0) {
@@ -849,16 +700,7 @@ export class KnownWordCacheManager {
continue; continue;
} }
this.noteEntriesById.set(noteId, normalizedEntries); this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToIndexes(noteId, normalizedEntries); this.addEntriesToCounts(normalizedEntries);
}
if (parsed.version === 4) {
for (const [noteIdKey, tier] of Object.entries(parsed.tiers)) {
const noteId = Number.parseInt(noteIdKey, 10);
const sanitizedTier = sanitizeKnownWordMaturityTier(tier);
if (sanitizedTier && this.noteEntriesById.has(noteId)) {
this.noteTierById.set(noteId, sanitizedTier);
}
}
} }
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs; this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope; this.knownWordsStateKey = parsed.scope;
@@ -881,7 +723,7 @@ export class KnownWordCacheManager {
continue; continue;
} }
this.noteEntriesById.set(noteId, normalizedEntries); this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToIndexes(noteId, normalizedEntries); this.addEntriesToCounts(normalizedEntries);
} }
this.knownWordsStateKey = parsed.scope; this.knownWordsStateKey = parsed.scope;
return; return;
@@ -899,23 +741,17 @@ export class KnownWordCacheManager {
private persistKnownWordCacheState(): void { private persistKnownWordCacheState(): void {
try { try {
const notes: Record<string, KnownWordEntry[]> = {}; const notes: Record<string, KnownWordEntry[]> = {};
const tiers: Record<string, KnownWordMaturityTier> = {};
for (const [noteId, entries] of this.noteEntriesById.entries()) { for (const [noteId, entries] of this.noteEntriesById.entries()) {
if (entries.length > 0) { if (entries.length > 0) {
notes[String(noteId)] = entries; notes[String(noteId)] = entries;
const tier = this.noteTierById.get(noteId);
if (tier) {
tiers[String(noteId)] = tier;
}
} }
} }
const state: KnownWordCacheStateV4 = { const state: KnownWordCacheStateV3 = {
version: 4, version: 3,
refreshedAtMs: this.knownWordsLastRefreshedAtMs, refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsStateKey, scope: this.knownWordsStateKey,
notes, notes,
tiers,
}; };
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8'); fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
} catch (error) { } catch (error) {
@@ -926,33 +762,18 @@ export class KnownWordCacheManager {
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState { private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>; const candidate = value as Record<string, unknown>;
if ( if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
candidate.version !== 1 &&
candidate.version !== 2 &&
candidate.version !== 3 &&
candidate.version !== 4
) {
return false; return false;
} }
if (typeof candidate.refreshedAtMs !== 'number') return false; if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false; if (typeof candidate.scope !== 'string') return false;
if (candidate.version === 1 || candidate.version === 2) { if (candidate.version !== 3) {
if (!Array.isArray(candidate.words)) return false; if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) { if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
return false; return false;
} }
} }
if (candidate.version === 4) { if (candidate.version === 2 || candidate.version === 3) {
// Per-tier values are sanitized entry-by-entry at load time.
if (
typeof candidate.tiers !== 'object' ||
candidate.tiers === null ||
Array.isArray(candidate.tiers)
) {
return false;
}
}
if (candidate.version === 2 || candidate.version === 3 || candidate.version === 4) {
if ( if (
typeof candidate.notes !== 'object' || typeof candidate.notes !== 'object' ||
candidate.notes === null || candidate.notes === null ||
@@ -1,94 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { AnkiConnectConfig } from '../types/anki';
import {
DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS,
buildKnownWordMaturityTierQueries,
classifyKnownWordNoteTier,
getKnownWordMaturityEnabled,
getMatureIntervalThresholdDays,
maxKnownWordMaturityTier,
sanitizeKnownWordMaturityTier,
} from './known-word-maturity';
function makeConfig(knownWords: AnkiConnectConfig['knownWords']): AnkiConnectConfig {
return { url: 'http://127.0.0.1:8765', knownWords } as AnkiConnectConfig;
}
test('maturity is enabled only when both highlight and maturity flags are on', () => {
assert.equal(
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: true })),
true,
);
assert.equal(
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: false, maturityEnabled: true })),
false,
);
assert.equal(
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: false })),
false,
);
assert.equal(getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true })), false);
assert.equal(getKnownWordMaturityEnabled(makeConfig(undefined)), false);
});
test('mature threshold falls back to default for invalid values', () => {
assert.equal(DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS, 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 30 })), 30);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 14.9 })), 14);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 0 })), 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: -5 })), 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: Number.NaN })), 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig({})), 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig(undefined)), 21);
});
test('tier queries append Anki search props to a deck scope query', () => {
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21');
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21');
assert.equal(queries.learning, 'deck:"Mining" is:learn');
});
test('tier queries with an empty scope query have no leading space', () => {
const queries = buildKnownWordMaturityTierQueries('', 30);
assert.equal(queries.mature, 'prop:ivl>=30');
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30');
assert.equal(queries.learning, 'is:learn');
});
test('note classification picks the most mature matching tier', () => {
const sets = {
mature: new Set([1, 4]),
young: new Set([2, 4]),
learning: new Set([3, 4, 2]),
};
assert.equal(classifyKnownWordNoteTier(1, sets), 'mature');
assert.equal(classifyKnownWordNoteTier(2, sets), 'young');
assert.equal(classifyKnownWordNoteTier(3, sets), 'learning');
// Note with mature, young, and learning cards: most mature card wins.
assert.equal(classifyKnownWordNoteTier(4, sets), 'mature');
assert.equal(classifyKnownWordNoteTier(99, sets), 'new');
});
test('maxKnownWordMaturityTier picks the higher tier and tolerates null', () => {
assert.equal(maxKnownWordMaturityTier('mature', 'new'), 'mature');
assert.equal(maxKnownWordMaturityTier('learning', 'young'), 'young');
assert.equal(maxKnownWordMaturityTier('new', null), 'new');
assert.equal(maxKnownWordMaturityTier(null, 'learning'), 'learning');
assert.equal(maxKnownWordMaturityTier(null, null), null);
assert.equal(maxKnownWordMaturityTier(undefined, undefined), null);
});
test('sanitizeKnownWordMaturityTier accepts only valid tiers', () => {
assert.equal(sanitizeKnownWordMaturityTier('mature'), 'mature');
assert.equal(sanitizeKnownWordMaturityTier('young'), 'young');
assert.equal(sanitizeKnownWordMaturityTier('learning'), 'learning');
assert.equal(sanitizeKnownWordMaturityTier('new'), 'new');
assert.equal(sanitizeKnownWordMaturityTier('MATURE'), null);
assert.equal(sanitizeKnownWordMaturityTier(''), null);
assert.equal(sanitizeKnownWordMaturityTier(21), null);
assert.equal(sanitizeKnownWordMaturityTier(null), null);
assert.equal(sanitizeKnownWordMaturityTier(undefined), null);
});
-102
View File
@@ -1,102 +0,0 @@
import type { AnkiConnectConfig } from '../types/anki';
import type { KnownWordMaturityTier } from '../types/subtitle';
export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21;
// Ascending maturity; index order backs tier comparison.
const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
export interface KnownWordMaturityTierQueries {
mature: string;
young: string;
learning: string;
}
export interface KnownWordMaturityTierSets {
mature: ReadonlySet<number>;
young: ReadonlySet<number>;
learning: ReadonlySet<number>;
}
// Maturity tiers only affect how known-word highlights render, so both flags
// must be on before tier data is fetched or served.
export function getKnownWordMaturityEnabled(config: AnkiConnectConfig): boolean {
return (
config.knownWords?.highlightEnabled === true && config.knownWords?.maturityEnabled === true
);
}
export function getMatureIntervalThresholdDays(config: AnkiConnectConfig): number {
const threshold = config.knownWords?.matureThresholdDays;
if (typeof threshold === 'number' && Number.isFinite(threshold) && threshold >= 1) {
return Math.floor(threshold);
}
return DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS;
}
// Anki search props classify notes server-side: a note matches a tier query
// when ANY of its cards matches, which implements most-mature-card-wins for
// free once tiers are checked in mature > young > learning order.
export function buildKnownWordMaturityTierQueries(
scopeQuery: string,
thresholdDays: number,
): KnownWordMaturityTierQueries {
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
return {
mature: `${prefix}prop:ivl>=${thresholdDays}`,
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays}`,
learning: `${prefix}is:learn`,
};
}
export async function fetchKnownWordMaturityTierSets(
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>,
scopeQueries: string[],
thresholdDays: number,
): Promise<{ mature: Set<number>; young: Set<number>; learning: Set<number> }> {
const sets = {
mature: new Set<number>(),
young: new Set<number>(),
learning: new Set<number>(),
};
for (const scopeQuery of scopeQueries) {
const queries = buildKnownWordMaturityTierQueries(scopeQuery, thresholdDays);
for (const tier of ['mature', 'young', 'learning'] as const) {
const noteIds = (await findNotes(queries[tier], { maxRetries: 0 })) as number[];
if (!Array.isArray(noteIds)) {
continue;
}
for (const noteId of noteIds) {
if (Number.isInteger(noteId) && noteId > 0) {
sets[tier].add(noteId);
}
}
}
}
return sets;
}
export function classifyKnownWordNoteTier(
noteId: number,
sets: KnownWordMaturityTierSets,
): KnownWordMaturityTier {
if (sets.mature.has(noteId)) return 'mature';
if (sets.young.has(noteId)) return 'young';
if (sets.learning.has(noteId)) return 'learning';
return 'new';
}
export function maxKnownWordMaturityTier(
a: KnownWordMaturityTier | null | undefined,
b: KnownWordMaturityTier | null | undefined,
): KnownWordMaturityTier | null {
if (!a) return b ?? null;
if (!b) return a;
return TIER_ORDER.indexOf(a) >= TIER_ORDER.indexOf(b) ? a : b;
}
export function sanitizeKnownWordMaturityTier(value: unknown): KnownWordMaturityTier | null {
return typeof value === 'string' && TIER_ORDER.includes(value as KnownWordMaturityTier)
? (value as KnownWordMaturityTier)
: null;
}
-1
View File
@@ -2182,7 +2182,6 @@ test('runtime options registry is centralized', () => {
assert.deepEqual(ids, [ assert.deepEqual(ids, [
'anki.autoUpdateNewCards', 'anki.autoUpdateNewCards',
'subtitle.annotation.knownWords.highlightEnabled', 'subtitle.annotation.knownWords.highlightEnabled',
'subtitle.annotation.knownWords.maturityEnabled',
'subtitle.annotation.nPlusOne', 'subtitle.annotation.nPlusOne',
'subtitle.annotation.jlpt', 'subtitle.annotation.jlpt',
'subtitle.annotation.frequency', 'subtitle.annotation.frequency',
@@ -60,8 +60,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
}, },
knownWords: { knownWords: {
highlightEnabled: false, highlightEnabled: false,
maturityEnabled: false,
matureThresholdDays: 21,
refreshMinutes: 1440, refreshMinutes: 1440,
addMinedWordsImmediately: true, addMinedWordsImmediately: true,
matchMode: 'headword', matchMode: 'headword',
@@ -32,12 +32,6 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
backdropFilter: 'blur(6px)', backdropFilter: 'blur(6px)',
nPlusOneColor: '#c6a0f6', nPlusOneColor: '#c6a0f6',
knownWordColor: '#a6da95', knownWordColor: '#a6da95',
knownWordMaturityColors: {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#a6da95',
},
jlptColors: { jlptColors: {
N1: '#ed8796', N1: '#ed8796',
N2: '#f5a97f', N2: '#f5a97f',
@@ -295,20 +295,6 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.knownWords.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.knownWords.maturityEnabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.knownWords.maturityEnabled,
description:
'Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting.',
},
{
path: 'ankiConnect.knownWords.matureThresholdDays',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.knownWords.matureThresholdDays,
description:
'Card interval in days at which a known word counts as mature (Anki convention: 21).',
},
{ {
path: 'ankiConnect.knownWords.refreshMinutes', path: 'ankiConnect.knownWords.refreshMinutes',
kind: 'number', kind: 'number',
@@ -109,34 +109,6 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor, defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
description: 'Color used for the single N+1 target token subtitle highlight.', description: 'Color used for the single N+1 target token subtitle highlight.',
}, },
{
path: 'subtitleStyle.knownWordMaturityColors.new',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.new,
description:
'Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.learning',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.learning,
description:
'Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.young',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.young,
description:
'Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.mature',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.mature,
description:
'Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.',
},
{ {
path: 'subtitleStyle.frequencyDictionary.enabled', path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean', kind: 'boolean',
-16
View File
@@ -35,22 +35,6 @@ export function buildRuntimeOptionRegistry(
}, },
}), }),
}, },
{
id: 'subtitle.annotation.knownWords.maturityEnabled',
path: 'ankiConnect.knownWords.maturityEnabled',
label: 'Known Word Maturity Colors',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.knownWords.maturityEnabled,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: (value) => ({
knownWords: {
maturityEnabled: value === true,
},
}),
},
{ {
id: 'subtitle.annotation.nPlusOne', id: 'subtitle.annotation.nPlusOne',
path: 'ankiConnect.nPlusOne.enabled', path: 'ankiConnect.nPlusOne.enabled',
-26
View File
@@ -125,32 +125,6 @@ test('settings registry groups annotation display fields by config group', () =>
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color'); assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
}); });
test('settings registry groups maturity settings under Known Words with color controls', () => {
assert.equal(field('ankiConnect.knownWords.maturityEnabled').subsection, 'Known Words');
assert.equal(field('ankiConnect.knownWords.matureThresholdDays').subsection, 'Known Words');
for (const tier of ['new', 'learning', 'young', 'mature']) {
const tierField = field(`subtitleStyle.knownWordMaturityColors.${tier}`);
assert.equal(tierField.subsection, 'Known Words');
assert.equal(tierField.control, 'color');
}
});
test('settings registry orders maturity colors from least mature to mature', () => {
const knownWordsPaths = fields
.filter((candidate) => candidate.subsection === 'Known Words')
.map((candidate) => candidate.configPath);
assert.deepEqual(knownWordsPaths, [
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.maturityEnabled',
'subtitleStyle.knownWordColor',
'ankiConnect.knownWords.matureThresholdDays',
'subtitleStyle.knownWordMaturityColors.new',
'subtitleStyle.knownWordMaturityColors.learning',
'subtitleStyle.knownWordMaturityColors.young',
'subtitleStyle.knownWordMaturityColors.mature',
]);
});
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => { test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior'); assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words'); assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
-11
View File
@@ -163,12 +163,6 @@ const PATH_ORDER = new Map<string, number>(
'ankiConnect.proxy.enabled', 'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled', 'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled', 'ankiConnect.isKiku.enabled',
'subtitleStyle.knownWordColor',
'ankiConnect.knownWords.matureThresholdDays',
'subtitleStyle.knownWordMaturityColors.new',
'subtitleStyle.knownWordMaturityColors.learning',
'subtitleStyle.knownWordMaturityColors.young',
'subtitleStyle.knownWordMaturityColors.mature',
'subtitleStyle.fontColor', 'subtitleStyle.fontColor',
'subtitleStyle.backgroundColor', 'subtitleStyle.backgroundColor',
'subtitleStyle.hoverTokenColor', 'subtitleStyle.hoverTokenColor',
@@ -359,7 +353,6 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path.startsWith('ankiConnect.nPlusOne.') || path.startsWith('ankiConnect.nPlusOne.') ||
path.startsWith('subtitleStyle.frequencyDictionary.') || path.startsWith('subtitleStyle.frequencyDictionary.') ||
path.startsWith('subtitleStyle.jlptColors.') || path.startsWith('subtitleStyle.jlptColors.') ||
path.startsWith('subtitleStyle.knownWordMaturityColors.') ||
path === 'subtitleStyle.enableJlpt' || path === 'subtitleStyle.enableJlpt' ||
path === 'subtitleStyle.knownWordColor' || path === 'subtitleStyle.knownWordColor' ||
path === 'subtitleStyle.nPlusOneColor' || path === 'subtitleStyle.nPlusOneColor' ||
@@ -523,7 +516,6 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
return 'key-code'; return 'key-code';
} }
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color'; if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'color';
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list'; if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select'; if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
if (JSON_OBJECT_FIELDS.has(path)) return 'json'; if (JSON_OBJECT_FIELDS.has(path)) return 'json';
@@ -541,9 +533,6 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
function subsectionForPath(path: string): string | undefined { function subsectionForPath(path: string): string | undefined {
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words'; if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
if (path === 'ankiConnect.knownWords.maturityEnabled') return 'Known Words';
if (path === 'ankiConnect.knownWords.matureThresholdDays') return 'Known Words';
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'Known Words';
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1'; if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
if (path === 'subtitleStyle.knownWordColor') return 'Known Words'; if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1'; if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
-15
View File
@@ -10,7 +10,6 @@ import {
Token, Token,
FrequencyDictionaryLookup, FrequencyDictionaryLookup,
JlptLevel, JlptLevel,
KnownWordMaturityTier,
PartOfSpeech, PartOfSpeech,
} from '../../types'; } from '../../types';
import { import {
@@ -44,12 +43,6 @@ export type KnownWordLookupFn = (
options?: { allowReadingOnlyMatch?: boolean }, options?: { allowReadingOnlyMatch?: boolean },
) => boolean; ) => boolean;
export type KnownWordTierLookupFn = (
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
) => KnownWordMaturityTier | null;
export interface TokenizerServiceDeps { export interface TokenizerServiceDeps {
getYomitanExt: () => Extension | null; getYomitanExt: () => Extension | null;
getYomitanSession?: () => Session | null; getYomitanSession?: () => Session | null;
@@ -60,7 +53,6 @@ export interface TokenizerServiceDeps {
getYomitanParserInitPromise: () => Promise<boolean> | null; getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void; setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: KnownWordLookupFn; isKnownWord: KnownWordLookupFn;
getKnownWordTier?: KnownWordTierLookupFn;
getKnownWordMatchMode: () => NPlusOneMatchMode; getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean; getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null; getJlptLevel: (text: string) => JlptLevel | null;
@@ -96,7 +88,6 @@ export interface TokenizerDepsRuntimeOptions {
getYomitanParserInitPromise: () => Promise<boolean> | null; getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void; setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: KnownWordLookupFn; isKnownWord: KnownWordLookupFn;
getKnownWordTier?: KnownWordTierLookupFn;
getKnownWordMatchMode: () => NPlusOneMatchMode; getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean; getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null; getJlptLevel: (text: string) => JlptLevel | null;
@@ -213,11 +204,6 @@ async function applyAnnotationStage(
tokens, tokens,
{ {
isKnownWord: getKnownWordLookup(deps, options), isKnownWord: getKnownWordLookup(deps, options),
// Maturity tiers only refine known-word rendering, so they follow the
// known-word toggle rather than the N+1 gate.
...(options.knownWordsEnabled && deps.getKnownWordTier
? { getKnownWordTier: deps.getKnownWordTier }
: {}),
knownWordMatchMode: deps.getKnownWordMatchMode(), knownWordMatchMode: deps.getKnownWordMatchMode(),
getJlptLevel: deps.getJlptLevel, getJlptLevel: deps.getJlptLevel,
}, },
@@ -256,7 +242,6 @@ export function createTokenizerDepsRuntime(
getYomitanParserInitPromise: options.getYomitanParserInitPromise, getYomitanParserInitPromise: options.getYomitanParserInitPromise,
setYomitanParserInitPromise: options.setYomitanParserInitPromise, setYomitanParserInitPromise: options.setYomitanParserInitPromise,
isKnownWord: options.isKnownWord, isKnownWord: options.isKnownWord,
getKnownWordTier: options.getKnownWordTier,
getKnownWordMatchMode: options.getKnownWordMatchMode, getKnownWordMatchMode: options.getKnownWordMatchMode,
getKnownWordsEnabled: options.getKnownWordsEnabled, getKnownWordsEnabled: options.getKnownWordsEnabled,
getJlptLevel: options.getJlptLevel, getJlptLevel: options.getJlptLevel,
@@ -1,130 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { KnownWordMaturityTier, MergedToken, PartOfSpeech } from '../../../types';
import { annotateTokens, AnnotationStageDeps } from './annotation-stage';
function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
return {
surface: '猫',
reading: 'ネコ',
headword: '猫',
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
...overrides,
};
}
function makeDeps(overrides: Partial<AnnotationStageDeps> = {}): AnnotationStageDeps {
return {
isKnownWord: () => false,
knownWordMatchMode: 'headword',
getJlptLevel: () => null,
...overrides,
};
}
test('annotateTokens attaches maturity tier to known tokens', () => {
const tokens = [makeToken({ surface: '食べた', headword: '食べる', reading: 'タベタ' })];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '食べる',
getKnownWordTier: (text) => (text === '食べる' ? 'mature' : null),
}),
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, 'mature');
});
test('annotateTokens leaves maturity undefined without a tier lookup dep', () => {
const tokens = [makeToken()];
const result = annotateTokens(tokens, makeDeps({ isKnownWord: () => true }));
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, undefined);
});
test('annotateTokens leaves maturity undefined when the tier lookup has no data', () => {
const tokens = [makeToken()];
const result = annotateTokens(
tokens,
makeDeps({ isKnownWord: () => true, getKnownWordTier: () => null }),
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, undefined);
});
test('annotateTokens never attaches maturity to unknown tokens', () => {
const tokens = [makeToken()];
const result = annotateTokens(
tokens,
makeDeps({ isKnownWord: () => false, getKnownWordTier: () => 'mature' }),
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.knownMaturity, undefined);
});
test('annotateTokens resolves maturity through the kana reading fallback', () => {
// Token 大体 with a card mined in kana (だいたい): known status comes from
// the reading fallback, so the tier must follow the same path.
const tierByText = new Map<string, KnownWordMaturityTier>([['だいたい', 'young']]);
const seenTierLookups: Array<{
text: string;
reading: string | undefined;
allowReadingOnlyMatch: boolean | undefined;
}> = [];
const tokens = [makeToken({ surface: '大体', headword: '大体', reading: 'だいたい', endPos: 2 })];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'だいたい',
getKnownWordTier: (text, reading, options) => {
seenTierLookups.push({
text,
reading,
allowReadingOnlyMatch: options?.allowReadingOnlyMatch,
});
return tierByText.get(text) ?? null;
},
}),
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, 'young');
// The fallback lookup must opt out of reading-only matching, exactly like
// the boolean known-status fallback.
const fallbackLookup = seenTierLookups.find((lookup) => lookup.text === 'だいたい');
assert.equal(fallbackLookup?.allowReadingOnlyMatch, false);
});
test('annotateTokens keeps maturity on POS-excluded known tokens', () => {
// Known-word annotations survive the POS noise filter; the tier must too.
const tokens = [makeToken({ surface: 'の', headword: 'の', reading: '', pos1: '助詞' })];
const result = annotateTokens(
tokens,
makeDeps({ isKnownWord: () => true, getKnownWordTier: () => 'learning' }),
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, 'learning');
});
test('annotateTokens strips maturity when known-word annotation is disabled', () => {
const tokens = [makeToken({ knownMaturity: 'mature' })];
const result = annotateTokens(
tokens,
makeDeps({ isKnownWord: () => true, getKnownWordTier: () => 'mature' }),
{ knownWordsEnabled: false },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.knownMaturity, undefined);
});
@@ -7,13 +7,7 @@ import {
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG, DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
resolveAnnotationPos2ExclusionSet, resolveAnnotationPos2ExclusionSet,
} from '../../../token-pos2-exclusions'; } from '../../../token-pos2-exclusions';
import { import { JlptLevel, MergedToken, NPlusOneMatchMode, PartOfSpeech } from '../../../types';
JlptLevel,
KnownWordMaturityTier,
MergedToken,
NPlusOneMatchMode,
PartOfSpeech,
} from '../../../types';
import { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter'; import { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter';
import { import {
shouldExcludeTokenFromSubtitleAnnotations as sharedShouldExcludeTokenFromSubtitleAnnotations, shouldExcludeTokenFromSubtitleAnnotations as sharedShouldExcludeTokenFromSubtitleAnnotations,
@@ -42,11 +36,6 @@ export interface AnnotationStageDeps {
reading?: string, reading?: string,
options?: { allowReadingOnlyMatch?: boolean }, options?: { allowReadingOnlyMatch?: boolean },
) => boolean; ) => boolean;
getKnownWordTier?: (
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
) => KnownWordMaturityTier | null;
knownWordMatchMode: NPlusOneMatchMode; knownWordMatchMode: NPlusOneMatchMode;
getJlptLevel: (text: string) => JlptLevel | null; getJlptLevel: (text: string) => JlptLevel | null;
} }
@@ -627,30 +616,6 @@ function computeTokenKnownStatus(
); );
} }
// Maturity tier for a token already confirmed known, following the same
// primary + kana-fallback lookup sequence as computeTokenKnownStatus so the
// tier always describes a note the boolean match could have come from.
function computeTokenKnownMaturity(
token: MergedToken,
getKnownWordTier: NonNullable<AnnotationStageDeps['getKnownWordTier']>,
knownWordMatchMode: NPlusOneMatchMode,
): KnownWordMaturityTier | undefined {
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
const matchReading = resolveKnownWordReadingForMatch(token, knownWordMatchMode);
const primaryTier = matchText ? getKnownWordTier(matchText, matchReading) : null;
if (primaryTier) {
return primaryTier;
}
const fallbackReading = resolveCompleteTokenReading(token);
if (!fallbackReading || fallbackReading === matchText.trim()) {
return undefined;
}
return (
getKnownWordTier(fallbackReading, undefined, { allowReadingOnlyMatch: false }) ?? undefined
);
}
function filterTokenFrequencyRank( function filterTokenFrequencyRank(
token: MergedToken, token: MergedToken,
pos1Exclusions: ReadonlySet<string>, pos1Exclusions: ReadonlySet<string>,
@@ -712,11 +677,6 @@ export function annotateTokens(
: false; : false;
nPlusOneKnownStatuses[index] = isKnownForMatching; nPlusOneKnownStatuses[index] = isKnownForMatching;
const knownMaturity =
knownWordsEnabled && isKnownForMatching && deps.getKnownWordTier
? computeTokenKnownMaturity(token, deps.getKnownWordTier, deps.knownWordMatchMode)
: undefined;
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true; const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
// A confirmed character-name match must survive the POS noise filter: // A confirmed character-name match must survive the POS noise filter:
@@ -736,7 +696,6 @@ export function annotateTokens(
return { return {
...strippedToken, ...strippedToken,
isKnown: knownWordsEnabled ? isKnownForMatching : false, isKnown: knownWordsEnabled ? isKnownForMatching : false,
knownMaturity,
}; };
} }
@@ -753,7 +712,6 @@ export function annotateTokens(
return { return {
...token, ...token,
isKnown: knownWordsEnabled ? isKnownForMatching : false, isKnown: knownWordsEnabled ? isKnownForMatching : false,
knownMaturity,
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false, isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
frequencyRank, frequencyRank,
jlptLevel, jlptLevel,
-2
View File
@@ -4560,8 +4560,6 @@ const {
}, },
isKnownWord: (text, reading, options) => isKnownWord: (text, reading, options) =>
Boolean(appState.ankiIntegration?.isKnownWord(text, reading, options)), Boolean(appState.ankiIntegration?.isKnownWord(text, reading, options)),
getKnownWordTier: (text, reading, options) =>
appState.ankiIntegration?.getKnownWordTier(text, reading, options) ?? null,
recordLookup: (hit) => { recordLookup: (hit) => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
appState.immersionTracker?.recordLookup(hit); appState.immersionTracker?.recordLookup(hit);
@@ -42,12 +42,6 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
deps.recordLookup(hit); deps.recordLookup(hit);
return hit; return hit;
}, },
...(deps.getKnownWordTier
? {
getKnownWordTier: (text, reading, options) =>
deps.getKnownWordTier!(text, reading, options),
}
: {}),
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(), getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
...(deps.getKnownWordsEnabled ...(deps.getKnownWordsEnabled
? { ? {
-8
View File
@@ -116,10 +116,6 @@ export type RendererState = {
subtitleSidebarPausedByHover: boolean; subtitleSidebarPausedByHover: boolean;
knownWordColor: string; knownWordColor: string;
knownWordMaturityNewColor: string;
knownWordMaturityLearningColor: string;
knownWordMaturityYoungColor: string;
knownWordMaturityMatureColor: string;
nPlusOneColor: string; nPlusOneColor: string;
nameMatchEnabled: boolean; nameMatchEnabled: boolean;
nameMatchColor: string; nameMatchColor: string;
@@ -244,10 +240,6 @@ export function createRendererState(): RendererState {
subtitleSidebarPausedByHover: false, subtitleSidebarPausedByHover: false,
knownWordColor: '#a6da95', knownWordColor: '#a6da95',
knownWordMaturityNewColor: '#ee99a0',
knownWordMaturityLearningColor: '#b7bdf8',
knownWordMaturityYoungColor: '#91d7e3',
knownWordMaturityMatureColor: '#a6da95',
nPlusOneColor: '#c6a0f6', nPlusOneColor: '#c6a0f6',
nameMatchEnabled: false, nameMatchEnabled: false,
nameMatchColor: '#f5bde6', nameMatchColor: '#f5bde6',
-42
View File
@@ -1503,24 +1503,6 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
color: var(--subtitle-known-word-color, #a6da95); color: var(--subtitle-known-word-color, #a6da95);
} }
/* Anki maturity tiers ride on word-known and only override its color, so
hover/selection rules keyed on word-known keep applying. */
#subtitleRoot .word.word-known.word-maturity-new {
color: var(--subtitle-maturity-new-color, #ee99a0);
}
#subtitleRoot .word.word-known.word-maturity-learning {
color: var(--subtitle-maturity-learning-color, #b7bdf8);
}
#subtitleRoot .word.word-known.word-maturity-young {
color: var(--subtitle-maturity-young-color, #91d7e3);
}
#subtitleRoot .word.word-known.word-maturity-mature {
color: var(--subtitle-maturity-mature-color, #a6da95);
}
#subtitleRoot .word.word-n-plus-one { #subtitleRoot .word.word-n-plus-one {
color: var(--subtitle-n-plus-one-color, #c6a0f6); color: var(--subtitle-n-plus-one-color, #c6a0f6);
} }
@@ -1698,30 +1680,6 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
-webkit-text-fill-color: var(--subtitle-known-word-color, #a6da95) !important; -webkit-text-fill-color: var(--subtitle-known-word-color, #a6da95) !important;
} }
#subtitleRoot .word.word-known.word-maturity-new::selection,
#subtitleRoot .word.word-known.word-maturity-new .c::selection {
color: var(--subtitle-maturity-new-color, #ee99a0) !important;
-webkit-text-fill-color: var(--subtitle-maturity-new-color, #ee99a0) !important;
}
#subtitleRoot .word.word-known.word-maturity-learning::selection,
#subtitleRoot .word.word-known.word-maturity-learning .c::selection {
color: var(--subtitle-maturity-learning-color, #b7bdf8) !important;
-webkit-text-fill-color: var(--subtitle-maturity-learning-color, #b7bdf8) !important;
}
#subtitleRoot .word.word-known.word-maturity-young::selection,
#subtitleRoot .word.word-known.word-maturity-young .c::selection {
color: var(--subtitle-maturity-young-color, #91d7e3) !important;
-webkit-text-fill-color: var(--subtitle-maturity-young-color, #91d7e3) !important;
}
#subtitleRoot .word.word-known.word-maturity-mature::selection,
#subtitleRoot .word.word-known.word-maturity-mature .c::selection {
color: var(--subtitle-maturity-mature-color, #a6da95) !important;
-webkit-text-fill-color: var(--subtitle-maturity-mature-color, #a6da95) !important;
}
#subtitleRoot .word.word-n-plus-one::selection, #subtitleRoot .word.word-n-plus-one::selection,
#subtitleRoot .word.word-n-plus-one .c::selection { #subtitleRoot .word.word-n-plus-one .c::selection {
color: var(--subtitle-n-plus-one-color, #c6a0f6) !important; color: var(--subtitle-n-plus-one-color, #c6a0f6) !important;
@@ -204,51 +204,3 @@ test('computeWordClass skips frequency class when rank is out of topX', () => {
assert.equal(actual, 'word'); assert.equal(actual, 'word');
}); });
test('computeWordClass adds the maturity tier class alongside word-known', () => {
const token = createToken({
isKnown: true,
knownMaturity: 'mature',
surface: '猫',
});
assert.equal(computeWordClass(token), 'word word-known word-maturity-mature');
});
test('computeWordClass keeps the plain known class when no maturity tier is set', () => {
const token = createToken({
isKnown: true,
surface: '猫',
});
assert.equal(computeWordClass(token), 'word word-known');
});
test('computeWordClass composes maturity with JLPT classes', () => {
const token = createToken({
isKnown: true,
knownMaturity: 'young',
jlptLevel: 'N3',
surface: '猫',
});
assert.equal(computeWordClass(token), 'word word-known word-maturity-young word-jlpt-n3');
});
test('computeWordClass gives n+1 and name matches precedence over maturity', () => {
const nPlusOne = createToken({
isKnown: true,
knownMaturity: 'mature',
isNPlusOneTarget: true,
surface: '犬',
});
assert.equal(computeWordClass(nPlusOne), 'word word-n-plus-one');
const nameMatch = createToken({
isKnown: true,
knownMaturity: 'mature',
surface: 'アクア',
}) as MergedToken & { isNameMatch?: boolean };
nameMatch.isNameMatch = true;
assert.equal(computeWordClass(nameMatch, { nameMatchEnabled: true }), 'word word-name-match');
});
-67
View File
@@ -1445,70 +1445,3 @@ test('secondary subtitle root CSS caps height so hover-pause band stays a top st
assert.match(secondaryRootBlock, /max-height:\s*6em;/); assert.match(secondaryRootBlock, /max-height:\s*6em;/);
assert.match(secondaryRootBlock, /overflow:\s*hidden;/); assert.match(secondaryRootBlock, /overflow:\s*hidden;/);
}); });
test('applySubtitleStyle sets known-word maturity color variables', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const subtitleContainer = new FakeElement('div');
const secondarySubRoot = new FakeElement('div');
const secondarySubContainer = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer,
secondarySubRoot,
secondarySubContainer,
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.applySubtitleStyle({
knownWordMaturityColors: {
new: '#111111',
learning: '#222222',
young: '#333333',
mature: '#444444',
},
} as never);
const values = (subtitleRoot.style as unknown as { values?: Map<string, string> }).values;
assert.equal(values?.get('--subtitle-maturity-new-color'), '#111111');
assert.equal(values?.get('--subtitle-maturity-learning-color'), '#222222');
assert.equal(values?.get('--subtitle-maturity-young-color'), '#333333');
assert.equal(values?.get('--subtitle-maturity-mature-color'), '#444444');
} finally {
restoreDocument();
}
});
test('applySubtitleStyle falls back to default maturity colors', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const subtitleContainer = new FakeElement('div');
const secondarySubRoot = new FakeElement('div');
const secondarySubContainer = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer,
secondarySubRoot,
secondarySubContainer,
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.applySubtitleStyle({} as never);
const values = (subtitleRoot.style as unknown as { values?: Map<string, string> }).values;
assert.equal(values?.get('--subtitle-maturity-new-color'), '#ee99a0');
assert.equal(values?.get('--subtitle-maturity-learning-color'), '#b7bdf8');
assert.equal(values?.get('--subtitle-maturity-young-color'), '#91d7e3');
assert.equal(values?.get('--subtitle-maturity-mature-color'), '#a6da95');
} finally {
restoreDocument();
}
});
-33
View File
@@ -597,11 +597,6 @@ export function computeWordClass(
classes.push('word-n-plus-one'); classes.push('word-n-plus-one');
} else if (token.isKnown) { } else if (token.isKnown) {
classes.push('word-known'); classes.push('word-known');
// The maturity class rides on word-known so hover/selection rules keyed
// on word-known keep applying; it only overrides the color.
if (token.knownMaturity) {
classes.push(`word-maturity-${token.knownMaturity}`);
}
} }
if (!hasPrioritizedNameMatch(token, resolvedTokenRenderSettings) && token.jlptLevel) { if (!hasPrioritizedNameMatch(token, resolvedTokenRenderSettings) && token.jlptLevel) {
@@ -872,39 +867,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
: {}), : {}),
}; };
const maturityColorOverrides = style.knownWordMaturityColors;
const maturityColors = {
new: sanitizeHexColor(maturityColorOverrides?.new, ctx.state.knownWordMaturityNewColor),
learning: sanitizeHexColor(
maturityColorOverrides?.learning,
ctx.state.knownWordMaturityLearningColor,
),
young: sanitizeHexColor(maturityColorOverrides?.young, ctx.state.knownWordMaturityYoungColor),
mature: sanitizeHexColor(
maturityColorOverrides?.mature,
ctx.state.knownWordMaturityMatureColor,
),
};
ctx.state.knownWordColor = knownWordColor; ctx.state.knownWordColor = knownWordColor;
ctx.state.knownWordMaturityNewColor = maturityColors.new;
ctx.state.knownWordMaturityLearningColor = maturityColors.learning;
ctx.state.knownWordMaturityYoungColor = maturityColors.young;
ctx.state.knownWordMaturityMatureColor = maturityColors.mature;
ctx.state.nPlusOneColor = nPlusOneColor; ctx.state.nPlusOneColor = nPlusOneColor;
ctx.state.nameMatchEnabled = nameMatchEnabled; ctx.state.nameMatchEnabled = nameMatchEnabled;
ctx.state.nameMatchColor = nameMatchColor; ctx.state.nameMatchColor = nameMatchColor;
ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor); ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-maturity-new-color', maturityColors.new);
ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-maturity-learning-color',
maturityColors.learning,
);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-maturity-young-color', maturityColors.young);
ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-maturity-mature-color',
maturityColors.mature,
);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor); ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-name-match-color', nameMatchColor); ctx.dom.subtitleRoot.style.setProperty('--subtitle-name-match-color', nameMatchColor);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-hover-token-color', hoverTokenColor); ctx.dom.subtitleRoot.style.setProperty('--subtitle-hover-token-color', hoverTokenColor);
-1
View File
@@ -54,7 +54,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
'anki.autoUpdateNewCards', 'anki.autoUpdateNewCards',
'subtitle.annotation.knownWords.highlightEnabled', 'subtitle.annotation.knownWords.highlightEnabled',
'subtitle.annotation.knownWords.maturityEnabled',
'subtitle.annotation.nPlusOne', 'subtitle.annotation.nPlusOne',
'subtitle.annotation.jlpt', 'subtitle.annotation.jlpt',
'subtitle.annotation.frequency', 'subtitle.annotation.frequency',
-2
View File
@@ -82,8 +82,6 @@ export interface AnkiConnectConfig {
}; };
knownWords?: { knownWords?: {
highlightEnabled?: boolean; highlightEnabled?: boolean;
maturityEnabled?: boolean;
matureThresholdDays?: number;
refreshMinutes?: number; refreshMinutes?: number;
addMinedWordsImmediately?: boolean; addMinedWordsImmediately?: boolean;
matchMode?: NPlusOneMatchMode; matchMode?: NPlusOneMatchMode;
-2
View File
@@ -254,8 +254,6 @@ export interface ResolvedConfig {
}; };
knownWords: { knownWords: {
highlightEnabled: boolean; highlightEnabled: boolean;
maturityEnabled: boolean;
matureThresholdDays: number;
refreshMinutes: number; refreshMinutes: number;
addMinedWordsImmediately: boolean; addMinedWordsImmediately: boolean;
matchMode: NPlusOneMatchMode; matchMode: NPlusOneMatchMode;
-1
View File
@@ -1,7 +1,6 @@
export type RuntimeOptionId = export type RuntimeOptionId =
| 'anki.autoUpdateNewCards' | 'anki.autoUpdateNewCards'
| 'subtitle.annotation.knownWords.highlightEnabled' | 'subtitle.annotation.knownWords.highlightEnabled'
| 'subtitle.annotation.knownWords.maturityEnabled'
| 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.nPlusOne'
| 'subtitle.annotation.jlpt' | 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency' | 'subtitle.annotation.frequency'
-11
View File
@@ -39,8 +39,6 @@ export interface MergedToken {
pos3?: string; pos3?: string;
isMerged: boolean; isMerged: boolean;
isKnown: boolean; isKnown: boolean;
/** Anki card maturity for a known token; unset when tier data is unavailable. */
knownMaturity?: KnownWordMaturityTier;
isNPlusOneTarget: boolean; isNPlusOneTarget: boolean;
/** /**
* Text Yomitan had no dictionary entry for (e.g. elongation runs, * Text Yomitan had no dictionary entry for (e.g. elongation runs,
@@ -63,9 +61,6 @@ export type FrequencyDictionaryLookup = (term: string) => number | null;
export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5'; export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5';
/** Anki card maturity tier for a known word, most mature card wins. */
export type KnownWordMaturityTier = 'new' | 'learning' | 'young' | 'mature';
export interface SubtitlePosition { export interface SubtitlePosition {
yPercent: number; yPercent: number;
} }
@@ -117,12 +112,6 @@ export interface SubtitleStyleConfig {
backgroundColor?: string; backgroundColor?: string;
nPlusOneColor?: string; nPlusOneColor?: string;
knownWordColor?: string; knownWordColor?: string;
knownWordMaturityColors?: {
new: string;
learning: string;
young: string;
mature: string;
};
jlptColors?: { jlptColors?: {
N1: string; N1: string;
N2: string; N2: string;