From 77badd7a54fb4049ce95a450d47d8d498ba0c8a8 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 25 Jul 2026 01:47:45 -0700 Subject: [PATCH] feat(anki): resolve known-word maturity config and show tiers in help le - Add `ankiConnect.knownWords.maturityEnabled`/`matureThresholdDays` config resolution with validation, warnings, and fallback to defaults - Session help color legend shows one row per maturity tier (new/learning/young/mature) when maturity coloring is on, instead of a single known-words swatch - Update docs and changelog entry to describe the new legend behavior --- .../anki-maturity-known-word-highlighting.md | 2 +- docs-site/subtitle-annotations.md | 2 + src/config/resolve/anki-connect.test.ts | 51 ++++++++++++++ .../resolve/anki-connect/known-words.ts | 40 +++++++++++ src/renderer/modals/session-help-colors.ts | 65 ++++++++++++++++-- src/renderer/modals/session-help-sections.ts | 5 +- src/renderer/modals/session-help.test.ts | 68 +++++++++++++++++++ src/renderer/modals/session-help.ts | 44 +++++++++--- 8 files changed, 259 insertions(+), 18 deletions(-) diff --git a/changes/anki-maturity-known-word-highlighting.md b/changes/anki-maturity-known-word-highlighting.md index ce8bfbe8..494f7ade 100644 --- a/changes/anki-maturity-known-word-highlighting.md +++ b/changes/anki-maturity-known-word-highlighting.md @@ -1,4 +1,4 @@ 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. +- 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. The session help color legend shows the four tier colors while maturity highlighting is on. diff --git a/docs-site/subtitle-annotations.md b/docs-site/subtitle-annotations.md index aefb4a6b..3e2dbb39 100644 --- a/docs-site/subtitle-annotations.md +++ b/docs-site/subtitle-annotations.md @@ -67,6 +67,8 @@ Instead of one color for every known word, maturity highlighting tints each know Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched. +While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature). + ## 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. diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts index 7990936a..6b469208 100644 --- a/src/config/resolve/anki-connect.test.ts +++ b/src/config/resolve/anki-connect.test.ts @@ -328,3 +328,54 @@ test('warns and falls back for invalid proxy settings', () => { assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.port')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.upstreamUrl')); }); + +test('resolves knownWords maturity settings from config', () => { + const { context, warnings } = makeContext({ + knownWords: { highlightEnabled: true, maturityEnabled: true, matureThresholdDays: 30 }, + }); + + applyAnkiConnectResolution(context); + + assert.equal(context.resolved.ankiConnect.knownWords.maturityEnabled, true); + assert.equal(context.resolved.ankiConnect.knownWords.matureThresholdDays, 30); + assert.deepEqual(warnings, []); +}); + +test('warns and falls back for invalid knownWords maturity settings', () => { + const { context, warnings } = makeContext({ + knownWords: { maturityEnabled: 'yes', matureThresholdDays: 0 }, + }); + + applyAnkiConnectResolution(context); + + assert.equal( + context.resolved.ankiConnect.knownWords.maturityEnabled, + DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled, + ); + assert.equal( + context.resolved.ankiConnect.knownWords.matureThresholdDays, + DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.maturityEnabled')); + assert.ok( + warnings.some((warning) => warning.path === 'ankiConnect.knownWords.matureThresholdDays'), + ); +}); + +test('omitted knownWords maturity settings fall back to defaults', () => { + const { context, warnings } = makeContext({ + knownWords: { highlightEnabled: true }, + }); + + applyAnkiConnectResolution(context); + + assert.equal( + context.resolved.ankiConnect.knownWords.maturityEnabled, + DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled, + ); + assert.equal( + context.resolved.ankiConnect.knownWords.matureThresholdDays, + DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays, + ); + assert.deepEqual(warnings, []); +}); diff --git a/src/config/resolve/anki-connect/known-words.ts b/src/config/resolve/anki-connect/known-words.ts index 31bbf5c6..86204802 100644 --- a/src/config/resolve/anki-connect/known-words.ts +++ b/src/config/resolve/anki-connect/known-words.ts @@ -49,6 +49,46 @@ export function applyAnkiKnownWordsResolution( } } + const knownWordsMaturityEnabled = asBoolean(knownWordsConfig.maturityEnabled); + if (knownWordsMaturityEnabled !== undefined) { + context.resolved.ankiConnect.knownWords.maturityEnabled = knownWordsMaturityEnabled; + } else if (hasOwn(knownWordsConfig, 'maturityEnabled')) { + context.warn( + 'ankiConnect.knownWords.maturityEnabled', + knownWordsConfig.maturityEnabled, + context.resolved.ankiConnect.knownWords.maturityEnabled, + 'Expected boolean.', + ); + context.resolved.ankiConnect.knownWords.maturityEnabled = + DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled; + } else { + context.resolved.ankiConnect.knownWords.maturityEnabled = + DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled; + } + + const knownWordsMatureThresholdDays = asNumber(knownWordsConfig.matureThresholdDays); + const hasValidMatureThresholdDays = + knownWordsMatureThresholdDays !== undefined && + Number.isInteger(knownWordsMatureThresholdDays) && + knownWordsMatureThresholdDays >= 1; + if (hasOwn(knownWordsConfig, 'matureThresholdDays')) { + if (hasValidMatureThresholdDays) { + context.resolved.ankiConnect.knownWords.matureThresholdDays = knownWordsMatureThresholdDays; + } else { + context.warn( + 'ankiConnect.knownWords.matureThresholdDays', + knownWordsConfig.matureThresholdDays, + DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays, + 'Expected an integer of at least 1.', + ); + context.resolved.ankiConnect.knownWords.matureThresholdDays = + DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays; + } + } else { + context.resolved.ankiConnect.knownWords.matureThresholdDays = + DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays; + } + const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes); const hasValidKnownWordsRefreshMinutes = knownWordsRefreshMinutes !== undefined && diff --git a/src/renderer/modals/session-help-colors.ts b/src/renderer/modals/session-help-colors.ts index 969e76ce..abee9966 100644 --- a/src/renderer/modals/session-help-colors.ts +++ b/src/renderer/modals/session-help-colors.ts @@ -2,6 +2,12 @@ import type { SessionHelpSection } from './session-help-sections'; export type SessionHelpSubtitleStyle = { knownWordColor?: unknown; + knownWordMaturityColors?: { + new?: unknown; + learning?: unknown; + young?: unknown; + mature?: unknown; + }; nPlusOneColor?: unknown; nameMatchColor?: unknown; jlptColors?: { @@ -13,10 +19,19 @@ export type SessionHelpSubtitleStyle = { }; }; +export type SessionHelpColorOptions = { + /** When true, known words are colored per Anki card maturity instead of one flat color. */ + knownWordMaturityEnabled?: boolean; +}; + const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; const FALLBACK_COLORS = { knownWordColor: '#a6da95', + knownWordMaturityNewColor: '#ee99a0', + knownWordMaturityLearningColor: '#b7bdf8', + knownWordMaturityYoungColor: '#91d7e3', + knownWordMaturityMatureColor: '#a6da95', nPlusOneColor: '#c6a0f6', nameMatchColor: '#f5bde6', jlptN1Color: '#ed8796', @@ -32,15 +47,53 @@ function normalizeColor(value: unknown, fallback: string): string { return HEX_COLOR_RE.test(next) ? next : fallback; } -export function buildColorSection(style: SessionHelpSubtitleStyle): SessionHelpSection { +function buildKnownWordRows( + style: SessionHelpSubtitleStyle, + options: SessionHelpColorOptions, +): SessionHelpSection['rows'] { + if (!options.knownWordMaturityEnabled) { + const knownWordColor = normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor); + return [{ shortcut: 'Known words', action: knownWordColor, color: knownWordColor }]; + } + + const maturityColors = style.knownWordMaturityColors; + const tiers: Array<{ label: string; value: unknown; fallback: string }> = [ + { + label: 'Known words (new)', + value: maturityColors?.new, + fallback: FALLBACK_COLORS.knownWordMaturityNewColor, + }, + { + label: 'Known words (learning)', + value: maturityColors?.learning, + fallback: FALLBACK_COLORS.knownWordMaturityLearningColor, + }, + { + label: 'Known words (young)', + value: maturityColors?.young, + fallback: FALLBACK_COLORS.knownWordMaturityYoungColor, + }, + { + label: 'Known words (mature)', + value: maturityColors?.mature, + fallback: FALLBACK_COLORS.knownWordMaturityMatureColor, + }, + ]; + + return tiers.map((tier) => { + const color = normalizeColor(tier.value, tier.fallback); + return { shortcut: tier.label, action: color, color }; + }); +} + +export function buildColorSection( + style: SessionHelpSubtitleStyle, + options: SessionHelpColorOptions = {}, +): SessionHelpSection { return { title: 'Color legend', rows: [ - { - shortcut: 'Known words', - action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor), - color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor), - }, + ...buildKnownWordRows(style, options), { shortcut: 'N+1 words', action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index 7395190b..513eafb6 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -403,6 +403,7 @@ export function buildSessionHelpSections(input: { markWatchedKey?: string | null; subtitleSidebarToggleKey?: string | null; subtitleStyle: SessionHelpSubtitleStyle | null | undefined; + knownWordMaturityEnabled?: boolean; }): SessionHelpSection[] { const sessionBindings = input.sessionBindings.filter((binding) => { if (binding.actionType !== 'session-action') return true; @@ -420,7 +421,9 @@ export function buildSessionHelpSections(input: { subtitleSidebarToggleKey: input.subtitleSidebarToggleKey, }), ...buildFixedOverlaySections(), - buildColorSection(input.subtitleStyle ?? {}), + buildColorSection(input.subtitleStyle ?? {}, { + knownWordMaturityEnabled: input.knownWordMaturityEnabled, + }), ]); } diff --git a/src/renderer/modals/session-help.test.ts b/src/renderer/modals/session-help.test.ts index 16f50ee3..59721ea2 100644 --- a/src/renderer/modals/session-help.test.ts +++ b/src/renderer/modals/session-help.test.ts @@ -104,6 +104,73 @@ test('session help builds rows from canonical session bindings and fixed overlay assert.ok(rows.some((row) => row.shortcut === 'Y then D' && row.action === 'Toggle DevTools')); }); +function colorLegendRows(input: Parameters[0]) { + const sections = buildSessionHelpSections(input); + return sections.find((section) => section.title === 'Color legend')?.rows ?? []; +} + +test('color legend shows the flat known-word color when maturity coloring is off', () => { + const rows = colorLegendRows({ + sessionBindings: [], + subtitleStyle: { + knownWordColor: '#a6da95', + knownWordMaturityColors: { + new: '#ee99a0', + learning: '#b7bdf8', + young: '#91d7e3', + mature: '#a6da95', + }, + }, + }); + + assert.deepEqual( + rows.filter((row) => row.shortcut.startsWith('Known words')), + [{ shortcut: 'Known words', action: '#a6da95', color: '#a6da95' }], + ); +}); + +test('color legend swaps in maturity tiers when maturity coloring is on', () => { + const rows = colorLegendRows({ + sessionBindings: [], + subtitleStyle: { + knownWordColor: '#a6da95', + knownWordMaturityColors: { + new: '#ee99a0', + learning: '#b7bdf8', + young: '#91d7e3', + mature: '#f0c6c6', + }, + }, + knownWordMaturityEnabled: true, + }); + + assert.deepEqual( + rows.filter((row) => row.shortcut.startsWith('Known words')), + [ + { shortcut: 'Known words (new)', action: '#ee99a0', color: '#ee99a0' }, + { shortcut: 'Known words (learning)', action: '#b7bdf8', color: '#b7bdf8' }, + { shortcut: 'Known words (young)', action: '#91d7e3', color: '#91d7e3' }, + { shortcut: 'Known words (mature)', action: '#f0c6c6', color: '#f0c6c6' }, + ], + ); + assert.ok(rows.some((row) => row.shortcut === 'N+1 words')); +}); + +test('color legend falls back to default maturity colors when overrides are invalid', () => { + const rows = colorLegendRows({ + sessionBindings: [], + subtitleStyle: { + knownWordMaturityColors: { new: 'not-a-color', learning: 42 }, + }, + knownWordMaturityEnabled: true, + }); + + assert.deepEqual( + rows.filter((row) => row.shortcut.startsWith('Known words')).map((row) => row.color), + ['#ee99a0', '#b7bdf8', '#91d7e3', '#a6da95'], + ); +}); + function createClassList(initialTokens: string[] = []) { const tokens = new Set(initialTokens); return { @@ -176,6 +243,7 @@ test('modal-layer session help does not focus hidden main overlay and still clos getSubtitleSidebarSnapshot: async () => ({ config: { toggleKey: 'Backslash' }, }), + getRuntimeOptions: async () => [], }, focus: () => {}, addEventListener: () => {}, diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index ed226bcb..5acabe85 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -19,6 +19,23 @@ type SessionHelpBindingInfo = { fallbackUnavailable: boolean; }; +/** + * Maturity coloring is a live runtime toggle, so the color legend reads it from + * runtime options instead of the resolved subtitle style. A missing or failing + * runtime-options call falls back to the flat known-word color. + */ +async function readKnownWordMaturityEnabled(): Promise { + try { + const runtimeOptions = await window.electronAPI.getRuntimeOptions(); + return runtimeOptions.some( + (option) => + option.id === 'subtitle.annotation.knownWords.maturityEnabled' && option.value === true, + ); + } catch { + return false; + } +} + function formatBindingHint(info: SessionHelpBindingInfo): string { if (info.bindingKey === 'KeyK' && info.fallbackUsed) { return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)'; @@ -219,22 +236,29 @@ export function createSessionHelpModal( async function render(): Promise { try { - const [sessionBindings, styleConfig, markWatchedKey, subtitleSidebarToggleKey] = - await Promise.all([ - window.electronAPI.getSessionBindings(), - window.electronAPI.getSubtitleStyle(), - window.electronAPI.getMarkWatchedKey(), - window.electronAPI - .getSubtitleSidebarSnapshot() - .then((snapshot) => snapshot.config.toggleKey) - .catch(() => undefined), - ]); + const [ + sessionBindings, + styleConfig, + markWatchedKey, + subtitleSidebarToggleKey, + knownWordMaturityEnabled, + ] = await Promise.all([ + window.electronAPI.getSessionBindings(), + window.electronAPI.getSubtitleStyle(), + window.electronAPI.getMarkWatchedKey(), + window.electronAPI + .getSubtitleSidebarSnapshot() + .then((snapshot) => snapshot.config.toggleKey) + .catch(() => undefined), + readKnownWordMaturityEnabled(), + ]); helpSections = buildSessionHelpSections({ sessionBindings, markWatchedKey, subtitleSidebarToggleKey, subtitleStyle: styleConfig ?? {}, + knownWordMaturityEnabled, }); applyFilterAndRender(); return true;