feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions

View File

@@ -20,21 +20,21 @@ function makeContext(ankiConnect: unknown): {
return { context, warnings };
}
test('modern invalid nPlusOne.highlightEnabled warns modern key and does not fallback to legacy', () => {
test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
const { context, warnings } = makeContext({
behavior: { nPlusOneHighlightEnabled: true },
nPlusOne: { highlightEnabled: 'yes' },
nPlusOne: { highlightEnabled: true },
knownWords: { highlightEnabled: 'yes' },
});
applyAnkiConnectResolution(context);
assert.equal(
context.resolved.ankiConnect.nPlusOne.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled'));
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.behavior.nPlusOneHighlightEnabled'),
warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'),
false,
);
});
@@ -53,18 +53,48 @@ test('normalizes ankiConnect tags by trimming and deduping', () => {
);
});
test('warns and falls back for invalid nPlusOne.decks entries', () => {
test('accepts knownWords.decks object format with field arrays', () => {
const { context, warnings } = makeContext({
nPlusOne: { decks: ['Core Deck', 123] },
knownWords: { decks: { 'Core Deck': ['Word', 'Reading'], Mining: ['Expression'] } },
});
applyAnkiConnectResolution(context);
assert.deepEqual(
context.resolved.ankiConnect.nPlusOne.decks,
DEFAULT_CONFIG.ankiConnect.nPlusOne.decks,
assert.deepEqual(context.resolved.ankiConnect.knownWords.decks, {
'Core Deck': ['Word', 'Reading'],
Mining: ['Expression'],
});
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'),
false,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks'));
});
test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
const { context, warnings } = makeContext({
knownWords: { addMinedWordsImmediately: false },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.addMinedWordsImmediately, false);
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.addMinedWordsImmediately'),
false,
);
});
test('converts legacy knownWords.decks array to object with default fields', () => {
const { context, warnings } = makeContext({
knownWords: { decks: ['Core Deck'] },
});
applyAnkiConnectResolution(context);
assert.deepEqual(context.resolved.ankiConnect.knownWords.decks, {
'Core Deck': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'));
});
test('accepts valid proxy settings', () => {
@@ -89,6 +119,52 @@ test('accepts valid proxy settings', () => {
);
});
test('accepts configured ankiConnect.fields.word override', () => {
const { context, warnings } = makeContext({
fields: {
word: 'TargetWord',
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.fields.word, 'TargetWord');
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.fields.word'),
false,
);
});
test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
const { context, warnings } = makeContext({
media: {
syncAnimatedImageToWordAudio: false,
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio, false);
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.media.syncAnimatedImageToWordAudio'),
false,
);
});
test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => {
const { context, warnings } = makeContext({
wordField: 'TargetWordLegacy',
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.fields.word, 'TargetWordLegacy');
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.wordField'),
false,
);
});
test('warns and falls back for invalid proxy settings', () => {
const { context, warnings } = makeContext({
proxy: {

View File

@@ -14,6 +14,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {};
const legacyKeys = new Set([
'wordField',
'audioField',
'imageField',
'sentenceField',
@@ -30,6 +31,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'animatedMaxWidth',
'animatedMaxHeight',
'animatedCrf',
'syncAnimatedImageToWordAudio',
'audioPadding',
'fallbackDuration',
'maxMediaDuration',
@@ -42,12 +44,13 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
]);
const {
knownWords: _knownWordsConfigFromAnkiConnect,
nPlusOne: _nPlusOneConfigFromAnkiConnect,
ai: _ankiAiConfig,
...ankiConnectWithoutNPlusOne
...ankiConnectWithoutKnownWordsOrNPlusOne
} = ac as Record<string, unknown>;
const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)),
Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !legacyKeys.has(key)),
);
context.resolved.ankiConnect = {
@@ -67,6 +70,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
? (ac.media as (typeof context.resolved)['ankiConnect']['media'])
: {}),
},
knownWords: {
...context.resolved.ankiConnect.knownWords,
},
behavior: {
...context.resolved.ankiConnect.behavior,
...(isObject(ac.behavior)
@@ -355,6 +361,17 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Expected string.',
);
}
if (!hasOwn(fields, 'word')) {
mapLegacy(
'wordField',
asString,
(value) => {
context.resolved.ankiConnect.fields.word = value;
},
context.resolved.ankiConnect.fields.word,
'Expected string.',
);
}
if (!hasOwn(fields, 'image')) {
mapLegacy(
'imageField',
@@ -520,6 +537,17 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Expected integer between 0 and 63.',
);
}
if (!hasOwn(media, 'syncAnimatedImageToWordAudio')) {
mapLegacy(
'syncAnimatedImageToWordAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio = value;
},
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio,
'Expected boolean.',
);
}
if (!hasOwn(media, 'audioPadding')) {
mapLegacy(
'audioPadding',
@@ -620,81 +648,145 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
);
}
const knownWordsConfig = isObject(ac.knownWords)
? (ac.knownWords as Record<string, unknown>)
: {};
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
const nPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
if (nPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.highlightEnabled = nPlusOneHighlightEnabled;
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
const legacyNPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
if (knownWordsHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
} else if (knownWordsConfig.highlightEnabled !== undefined) {
context.warn(
'ankiConnect.knownWords.highlightEnabled',
knownWordsConfig.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else if (legacyNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = legacyNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else if (nPlusOneConfig.highlightEnabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled,
context.resolved.ankiConnect.nPlusOne.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
const legacyNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.highlightEnabled = legacyNPlusOneHighlightEnabled;
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled =
legacyBehaviorNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.behavior.nPlusOneHighlightEnabled',
behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled',
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else {
context.resolved.ankiConnect.nPlusOne.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
}
}
const nPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
const hasValidNPlusOneRefreshMinutes =
nPlusOneRefreshMinutes !== undefined &&
Number.isInteger(nPlusOneRefreshMinutes) &&
nPlusOneRefreshMinutes > 0;
if (nPlusOneRefreshMinutes !== undefined) {
if (hasValidNPlusOneRefreshMinutes) {
context.resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes;
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const legacyNPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
const hasValidKnownWordsRefreshMinutes =
knownWordsRefreshMinutes !== undefined &&
Number.isInteger(knownWordsRefreshMinutes) &&
knownWordsRefreshMinutes > 0;
const hasValidLegacyNPlusOneRefreshMinutes =
legacyNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
legacyNPlusOneRefreshMinutes > 0;
if (knownWordsRefreshMinutes !== undefined) {
if (hasValidKnownWordsRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
} else {
context.warn(
'ankiConnect.knownWords.refreshMinutes',
knownWordsConfig.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (legacyNPlusOneRefreshMinutes !== undefined) {
if (hasValidLegacyNPlusOneRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else {
context.warn(
'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes,
context.resolved.ankiConnect.nPlusOne.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.nPlusOne.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
const legacyNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const hasValidLegacyRefreshMinutes =
legacyNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
legacyNPlusOneRefreshMinutes > 0;
legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
legacyBehaviorNPlusOneRefreshMinutes > 0;
if (hasValidLegacyRefreshMinutes) {
context.resolved.ankiConnect.nPlusOne.refreshMinutes = legacyNPlusOneRefreshMinutes;
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes',
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
context.resolved.ankiConnect.nPlusOne.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.nPlusOne.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else {
context.resolved.ankiConnect.nPlusOne.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately);
if (knownWordsAddMinedWordsImmediately !== undefined) {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
knownWordsAddMinedWordsImmediately;
} else if (knownWordsConfig.addMinedWordsImmediately !== undefined) {
context.warn(
'ankiConnect.knownWords.addMinedWordsImmediately',
knownWordsConfig.addMinedWordsImmediately,
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
} else {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
}
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
@@ -720,72 +812,138 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
}
const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidNPlusOneMatchMode =
nPlusOneMatchMode === 'headword' || nPlusOneMatchMode === 'surface';
const hasValidLegacyMatchMode =
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
const legacyNPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidKnownWordsMatchMode =
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
const hasValidLegacyNPlusOneMatchMode =
legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
if (hasValidNPlusOneMatchMode) {
context.resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode;
} else if (nPlusOneMatchMode !== undefined) {
const hasValidLegacyMatchMode =
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
if (hasValidKnownWordsMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
} else if (knownWordsMatchMode !== undefined) {
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
'ankiConnect.knownWords.matchMode',
knownWordsConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} else if (legacyNPlusOneMatchMode !== undefined) {
if (hasValidLegacyNPlusOneMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyNPlusOneMatchMode;
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) {
context.resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode;
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
'Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode',
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
context.resolved.ankiConnect.nPlusOne.matchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.nPlusOne.matchMode =
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else {
context.resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
const nPlusOneDecks = nPlusOneConfig.decks;
if (Array.isArray(nPlusOneDecks)) {
const normalizedDecks = nPlusOneDecks
const DEFAULT_FIELDS = [
DEFAULT_CONFIG.ankiConnect.fields.word,
'Word',
'Reading',
'Word Reading',
];
const knownWordsDecks = knownWordsConfig.decks;
const legacyNPlusOneDecks = nPlusOneConfig.decks;
if (isObject(knownWordsDecks)) {
const resolved: Record<string, string[]> = {};
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
const deckName = deck.trim();
if (!deckName) continue;
if (Array.isArray(fields) && fields.every((f) => typeof f === 'string')) {
resolved[deckName] = (fields as string[]).map((f) => f.trim()).filter((f) => f.length > 0);
} else {
context.warn(
`ankiConnect.knownWords.decks["${deckName}"]`,
fields,
DEFAULT_FIELDS,
'Expected an array of field name strings.',
);
resolved[deckName] = DEFAULT_FIELDS;
}
}
context.resolved.ankiConnect.knownWords.decks = resolved;
} else if (Array.isArray(knownWordsDecks)) {
const normalized = knownWordsDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedDecks.length === nPlusOneDecks.length) {
context.resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)];
} else if (nPlusOneDecks.length > 0) {
const resolved: Record<string, string[]> = {};
for (const deck of new Set(normalized)) {
resolved[deck] = DEFAULT_FIELDS;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
resolved,
'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }',
);
}
} else if (knownWordsDecks !== undefined) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
context.resolved.ankiConnect.knownWords.decks,
'Expected an object mapping deck names to field arrays.',
);
} else if (Array.isArray(legacyNPlusOneDecks)) {
const normalized = legacyNPlusOneDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
for (const deck of new Set(normalized)) {
resolved[deck] = DEFAULT_FIELDS;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn(
'ankiConnect.nPlusOne.decks',
nPlusOneDecks,
context.resolved.ankiConnect.nPlusOne.decks,
'Expected an array of strings.',
legacyNPlusOneDecks,
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
'Legacy key is deprecated; use ankiConnect.knownWords.decks with object format',
);
} else {
context.resolved.ankiConnect.nPlusOne.decks = [];
}
} else if (nPlusOneDecks !== undefined) {
context.warn(
'ankiConnect.nPlusOne.decks',
nPlusOneDecks,
context.resolved.ankiConnect.nPlusOne.decks,
'Expected an array of strings.',
);
context.resolved.ankiConnect.nPlusOne.decks = [];
}
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
@@ -801,17 +959,34 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne;
}
const nPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
if (nPlusOneKnownWordColor !== undefined) {
context.resolved.ankiConnect.nPlusOne.knownWord = nPlusOneKnownWordColor;
const knownWordsColor = asColor(knownWordsConfig.color);
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
if (knownWordsColor !== undefined) {
context.resolved.ankiConnect.knownWords.color = knownWordsColor;
} else if (knownWordsConfig.color !== undefined) {
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.ankiConnect.knownWords.color,
'Expected a hex color value.',
);
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
} else if (legacyNPlusOneKnownWordColor !== undefined) {
context.resolved.ankiConnect.knownWords.color = legacyNPlusOneKnownWordColor;
context.warn(
'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord,
DEFAULT_CONFIG.ankiConnect.knownWords.color,
'Legacy key is deprecated; use ankiConnect.knownWords.color',
);
} else if (nPlusOneConfig.knownWord !== undefined) {
context.warn(
'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord,
context.resolved.ankiConnect.nPlusOne.knownWord,
context.resolved.ankiConnect.knownWords.color,
'Expected a hex color value.',
);
context.resolved.ankiConnect.nPlusOne.knownWord = DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord;
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
}
if (

View File

@@ -1,9 +1,68 @@
import { ResolveContext } from './context';
import { ImmersionTrackingRetentionMode, ImmersionTrackingRetentionPreset } from '../../types';
import { asBoolean, asNumber, asString, isObject } from './shared';
const DEFAULT_RETENTION_MODE: ImmersionTrackingRetentionMode = 'preset';
const DEFAULT_RETENTION_PRESET: ImmersionTrackingRetentionPreset = 'balanced';
const BASE_RETENTION = {
eventsDays: 0,
telemetryDays: 0,
sessionsDays: 0,
dailyRollupsDays: 0,
monthlyRollupsDays: 0,
vacuumIntervalDays: 0,
};
const RETENTION_PRESETS: Record<ImmersionTrackingRetentionPreset, typeof BASE_RETENTION> = {
minimal: {
eventsDays: 3,
telemetryDays: 14,
sessionsDays: 14,
dailyRollupsDays: 30,
monthlyRollupsDays: 365,
vacuumIntervalDays: 7,
},
balanced: BASE_RETENTION,
'deep-history': {
eventsDays: 14,
telemetryDays: 60,
sessionsDays: 60,
dailyRollupsDays: 730,
monthlyRollupsDays: 5 * 365,
vacuumIntervalDays: 7,
},
};
const DEFAULT_LIFETIME_SUMMARIES = {
global: true,
anime: true,
media: true,
};
function asRetentionMode(value: unknown): value is ImmersionTrackingRetentionMode {
return value === 'preset' || value === 'advanced';
}
function asRetentionPreset(value: unknown): value is ImmersionTrackingRetentionPreset {
return value === 'minimal' || value === 'balanced' || value === 'deep-history';
}
export function applyImmersionTrackingConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (!isObject(src.immersionTracking)) {
resolved.immersionTracking.retentionMode = DEFAULT_RETENTION_MODE;
resolved.immersionTracking.retentionPreset = DEFAULT_RETENTION_PRESET;
resolved.immersionTracking.retention = {
...BASE_RETENTION,
};
resolved.immersionTracking.lifetimeSummaries = {
...DEFAULT_LIFETIME_SUMMARIES,
};
return;
}
if (isObject(src.immersionTracking)) {
const enabled = asBoolean(src.immersionTracking.enabled);
if (enabled !== undefined) {
@@ -93,81 +152,186 @@ export function applyImmersionTrackingConfig(context: ResolveContext): void {
);
}
const retentionMode = asString(src.immersionTracking.retentionMode);
if (asRetentionMode(retentionMode)) {
resolved.immersionTracking.retentionMode = retentionMode;
} else if (src.immersionTracking.retentionMode !== undefined) {
warn(
'immersionTracking.retentionMode',
src.immersionTracking.retentionMode,
DEFAULT_RETENTION_MODE,
'Expected "preset" or "advanced".',
);
resolved.immersionTracking.retentionMode = DEFAULT_RETENTION_MODE;
} else {
resolved.immersionTracking.retentionMode = DEFAULT_RETENTION_MODE;
}
const retentionPreset = asString(src.immersionTracking.retentionPreset);
if (asRetentionPreset(retentionPreset)) {
resolved.immersionTracking.retentionPreset = retentionPreset;
} else if (src.immersionTracking.retentionPreset !== undefined) {
warn(
'immersionTracking.retentionPreset',
src.immersionTracking.retentionPreset,
DEFAULT_RETENTION_PRESET,
'Expected "minimal", "balanced", or "deep-history".',
);
resolved.immersionTracking.retentionPreset = DEFAULT_RETENTION_PRESET;
} else {
resolved.immersionTracking.retentionPreset =
resolved.immersionTracking.retentionPreset ?? DEFAULT_RETENTION_PRESET;
}
const resolvedPreset =
resolved.immersionTracking.retentionPreset === 'minimal' ||
resolved.immersionTracking.retentionPreset === 'balanced' ||
resolved.immersionTracking.retentionPreset === 'deep-history'
? resolved.immersionTracking.retentionPreset
: DEFAULT_RETENTION_PRESET;
const baseRetention =
resolved.immersionTracking.retentionMode === 'preset'
? RETENTION_PRESETS[resolvedPreset]
: BASE_RETENTION;
const retention = {
eventsDays: baseRetention.eventsDays,
telemetryDays: baseRetention.telemetryDays,
sessionsDays: baseRetention.sessionsDays,
dailyRollupsDays: baseRetention.dailyRollupsDays,
monthlyRollupsDays: baseRetention.monthlyRollupsDays,
vacuumIntervalDays: baseRetention.vacuumIntervalDays,
};
if (isObject(src.immersionTracking.retention)) {
const eventsDays = asNumber(src.immersionTracking.retention.eventsDays);
if (eventsDays !== undefined && eventsDays >= 1 && eventsDays <= 3650) {
resolved.immersionTracking.retention.eventsDays = Math.floor(eventsDays);
if (eventsDays !== undefined && eventsDays >= 0 && eventsDays <= 3650) {
retention.eventsDays = Math.floor(eventsDays);
} else if (src.immersionTracking.retention.eventsDays !== undefined) {
warn(
'immersionTracking.retention.eventsDays',
src.immersionTracking.retention.eventsDays,
resolved.immersionTracking.retention.eventsDays,
'Expected integer between 1 and 3650.',
retention.eventsDays,
'Expected integer between 0 and 3650.',
);
}
const telemetryDays = asNumber(src.immersionTracking.retention.telemetryDays);
if (telemetryDays !== undefined && telemetryDays >= 1 && telemetryDays <= 3650) {
resolved.immersionTracking.retention.telemetryDays = Math.floor(telemetryDays);
if (telemetryDays !== undefined && telemetryDays >= 0 && telemetryDays <= 3650) {
retention.telemetryDays = Math.floor(telemetryDays);
} else if (src.immersionTracking.retention.telemetryDays !== undefined) {
warn(
'immersionTracking.retention.telemetryDays',
src.immersionTracking.retention.telemetryDays,
resolved.immersionTracking.retention.telemetryDays,
'Expected integer between 1 and 3650.',
retention.telemetryDays,
'Expected integer between 0 and 3650.',
);
}
const sessionsDays = asNumber(src.immersionTracking.retention.sessionsDays);
if (sessionsDays !== undefined && sessionsDays >= 0 && sessionsDays <= 3650) {
retention.sessionsDays = Math.floor(sessionsDays);
} else if (src.immersionTracking.retention.sessionsDays !== undefined) {
warn(
'immersionTracking.retention.sessionsDays',
src.immersionTracking.retention.sessionsDays,
retention.sessionsDays,
'Expected integer between 0 and 3650.',
);
}
const dailyRollupsDays = asNumber(src.immersionTracking.retention.dailyRollupsDays);
if (dailyRollupsDays !== undefined && dailyRollupsDays >= 1 && dailyRollupsDays <= 36500) {
resolved.immersionTracking.retention.dailyRollupsDays = Math.floor(dailyRollupsDays);
if (dailyRollupsDays !== undefined && dailyRollupsDays >= 0 && dailyRollupsDays <= 36500) {
retention.dailyRollupsDays = Math.floor(dailyRollupsDays);
} else if (src.immersionTracking.retention.dailyRollupsDays !== undefined) {
warn(
'immersionTracking.retention.dailyRollupsDays',
src.immersionTracking.retention.dailyRollupsDays,
resolved.immersionTracking.retention.dailyRollupsDays,
'Expected integer between 1 and 36500.',
retention.dailyRollupsDays,
'Expected integer between 0 and 36500.',
);
}
const monthlyRollupsDays = asNumber(src.immersionTracking.retention.monthlyRollupsDays);
if (
monthlyRollupsDays !== undefined &&
monthlyRollupsDays >= 1 &&
monthlyRollupsDays >= 0 &&
monthlyRollupsDays <= 36500
) {
resolved.immersionTracking.retention.monthlyRollupsDays = Math.floor(monthlyRollupsDays);
retention.monthlyRollupsDays = Math.floor(monthlyRollupsDays);
} else if (src.immersionTracking.retention.monthlyRollupsDays !== undefined) {
warn(
'immersionTracking.retention.monthlyRollupsDays',
src.immersionTracking.retention.monthlyRollupsDays,
resolved.immersionTracking.retention.monthlyRollupsDays,
'Expected integer between 1 and 36500.',
retention.monthlyRollupsDays,
'Expected integer between 0 and 36500.',
);
}
const vacuumIntervalDays = asNumber(src.immersionTracking.retention.vacuumIntervalDays);
if (
vacuumIntervalDays !== undefined &&
vacuumIntervalDays >= 1 &&
vacuumIntervalDays >= 0 &&
vacuumIntervalDays <= 3650
) {
resolved.immersionTracking.retention.vacuumIntervalDays = Math.floor(vacuumIntervalDays);
retention.vacuumIntervalDays = Math.floor(vacuumIntervalDays);
} else if (src.immersionTracking.retention.vacuumIntervalDays !== undefined) {
warn(
'immersionTracking.retention.vacuumIntervalDays',
src.immersionTracking.retention.vacuumIntervalDays,
resolved.immersionTracking.retention.vacuumIntervalDays,
'Expected integer between 1 and 3650.',
retention.vacuumIntervalDays,
'Expected integer between 0 and 3650.',
);
}
} else if (src.immersionTracking.retention !== undefined) {
warn(
'immersionTracking.retention',
src.immersionTracking.retention,
resolved.immersionTracking.retention,
baseRetention,
'Expected object.',
);
}
resolved.immersionTracking.retention = {
eventsDays: retention.eventsDays,
telemetryDays: retention.telemetryDays,
sessionsDays: retention.sessionsDays,
dailyRollupsDays: retention.dailyRollupsDays,
monthlyRollupsDays: retention.monthlyRollupsDays,
vacuumIntervalDays: retention.vacuumIntervalDays,
};
const lifetimeSummaries = {
global: DEFAULT_LIFETIME_SUMMARIES.global,
anime: DEFAULT_LIFETIME_SUMMARIES.anime,
media: DEFAULT_LIFETIME_SUMMARIES.media,
};
if (isObject(src.immersionTracking.lifetimeSummaries)) {
const global = asBoolean(src.immersionTracking.lifetimeSummaries.global);
if (global !== undefined) {
lifetimeSummaries.global = global;
}
const anime = asBoolean(src.immersionTracking.lifetimeSummaries.anime);
if (anime !== undefined) {
lifetimeSummaries.anime = anime;
}
const media = asBoolean(src.immersionTracking.lifetimeSummaries.media);
if (media !== undefined) {
lifetimeSummaries.media = media;
}
} else if (src.immersionTracking.lifetimeSummaries !== undefined) {
warn(
'immersionTracking.lifetimeSummaries',
src.immersionTracking.lifetimeSummaries,
DEFAULT_LIFETIME_SUMMARIES,
'Expected object.',
);
}
resolved.immersionTracking.lifetimeSummaries = lifetimeSummaries;
}
}

View File

@@ -0,0 +1,53 @@
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyStatsConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (!isObject(src.stats)) return;
const toggleKey = asString(src.stats.toggleKey);
if (toggleKey !== undefined) {
resolved.stats.toggleKey = toggleKey;
} else if (src.stats.toggleKey !== undefined) {
warn('stats.toggleKey', src.stats.toggleKey, resolved.stats.toggleKey, 'Expected string.');
}
const markWatchedKey = asString(src.stats.markWatchedKey);
if (markWatchedKey !== undefined) {
resolved.stats.markWatchedKey = markWatchedKey;
} else if (src.stats.markWatchedKey !== undefined) {
warn('stats.markWatchedKey', src.stats.markWatchedKey, resolved.stats.markWatchedKey, 'Expected string.');
}
const serverPort = asNumber(src.stats.serverPort);
if (serverPort !== undefined) {
resolved.stats.serverPort = serverPort;
} else if (src.stats.serverPort !== undefined) {
warn('stats.serverPort', src.stats.serverPort, resolved.stats.serverPort, 'Expected number.');
}
const autoStartServer = asBoolean(src.stats.autoStartServer);
if (autoStartServer !== undefined) {
resolved.stats.autoStartServer = autoStartServer;
} else if (src.stats.autoStartServer !== undefined) {
warn(
'stats.autoStartServer',
src.stats.autoStartServer,
resolved.stats.autoStartServer,
'Expected boolean.',
);
}
const autoOpenBrowser = asBoolean(src.stats.autoOpenBrowser);
if (autoOpenBrowser !== undefined) {
resolved.stats.autoOpenBrowser = autoOpenBrowser;
} else if (src.stats.autoOpenBrowser !== undefined) {
warn(
'stats.autoOpenBrowser',
src.stats.autoOpenBrowser,
resolved.stats.autoOpenBrowser,
'Expected boolean.',
);
}
}