mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
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:
@@ -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 &&
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user