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
This commit is contained in:
2026-07-25 01:47:45 -07:00
parent 33b9d97d63
commit 77badd7a54
8 changed files with 259 additions and 18 deletions
+51
View File
@@ -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, []);
});
@@ -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 &&
+59 -6
View File
@@ -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),
+4 -1
View File
@@ -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,
}),
]);
}
+68
View File
@@ -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<typeof buildSessionHelpSections>[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: () => {},
+34 -10
View File
@@ -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<boolean> {
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<boolean> {
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;