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

@@ -85,11 +85,17 @@ test('loads defaults when config is missing', () => {
assert.equal(config.immersionTracking.queueCap, 1000);
assert.equal(config.immersionTracking.payloadCapBytes, 256);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
assert.equal(config.immersionTracking.retention.eventsDays, 7);
assert.equal(config.immersionTracking.retention.telemetryDays, 30);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
assert.equal(config.immersionTracking.retention.eventsDays, 0);
assert.equal(config.immersionTracking.retention.telemetryDays, 0);
assert.equal(config.immersionTracking.retention.sessionsDays, 0);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 0);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 0);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 0);
assert.equal(config.immersionTracking.retentionMode, 'preset');
assert.equal(config.immersionTracking.retentionPreset, 'balanced');
assert.equal(config.immersionTracking.lifetimeSummaries?.global, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
});
test('throws actionable startup parse error for malformed config at construction time', () => {
@@ -742,12 +748,20 @@ test('accepts immersion tracking config values', () => {
"queueCap": 2000,
"payloadCapBytes": 512,
"maintenanceIntervalMs": 3600000,
"retentionMode": "preset",
"retentionPreset": "minimal",
"retention": {
"eventsDays": 14,
"telemetryDays": 45,
"sessionsDays": 60,
"dailyRollupsDays": 730,
"monthlyRollupsDays": 3650,
"vacuumIntervalDays": 14
},
"lifetimeSummaries": {
"global": false,
"anime": true,
"media": false
}
}
}`,
@@ -766,9 +780,15 @@ test('accepts immersion tracking config values', () => {
assert.equal(config.immersionTracking.maintenanceIntervalMs, 3_600_000);
assert.equal(config.immersionTracking.retention.eventsDays, 14);
assert.equal(config.immersionTracking.retention.telemetryDays, 45);
assert.equal(config.immersionTracking.retention.sessionsDays, 60);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 730);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 3650);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 14);
assert.equal(config.immersionTracking.retentionMode, 'preset');
assert.equal(config.immersionTracking.retentionPreset, 'minimal');
assert.equal(config.immersionTracking.lifetimeSummaries?.global, false);
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.media, false);
});
test('falls back for invalid immersion tracking tuning values', () => {
@@ -777,18 +797,22 @@ test('falls back for invalid immersion tracking tuning values', () => {
path.join(dir, 'config.jsonc'),
`{
"immersionTracking": {
"retentionMode": "bad",
"retentionPreset": "bad",
"batchSize": 0,
"flushIntervalMs": 1,
"queueCap": 5,
"payloadCapBytes": 16,
"maintenanceIntervalMs": 1000,
"retention": {
"eventsDays": 0,
"eventsDays": -1,
"telemetryDays": 99999,
"dailyRollupsDays": 0,
"sessionsDays": -1,
"dailyRollupsDays": -1,
"monthlyRollupsDays": 999999,
"vacuumIntervalDays": 0
}
"vacuumIntervalDays": -1
},
"lifetimeSummaries": "bad"
}
}`,
'utf-8',
@@ -803,11 +827,17 @@ test('falls back for invalid immersion tracking tuning values', () => {
assert.equal(config.immersionTracking.queueCap, 1000);
assert.equal(config.immersionTracking.payloadCapBytes, 256);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
assert.equal(config.immersionTracking.retention.eventsDays, 7);
assert.equal(config.immersionTracking.retention.telemetryDays, 30);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
assert.equal(config.immersionTracking.retention.eventsDays, 0);
assert.equal(config.immersionTracking.retention.telemetryDays, 0);
assert.equal(config.immersionTracking.retention.sessionsDays, 0);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 0);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 0);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 0);
assert.equal(config.immersionTracking.retentionMode, 'preset');
assert.equal(config.immersionTracking.retentionPreset, 'balanced');
assert.equal(config.immersionTracking.lifetimeSummaries?.global, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.batchSize'));
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.flushIntervalMs'));
@@ -818,6 +848,9 @@ test('falls back for invalid immersion tracking tuning values', () => {
assert.ok(
warnings.some((warning) => warning.path === 'immersionTracking.retention.telemetryDays'),
);
assert.ok(
warnings.some((warning) => warning.path === 'immersionTracking.retention.sessionsDays'),
);
assert.ok(
warnings.some((warning) => warning.path === 'immersionTracking.retention.dailyRollupsDays'),
);
@@ -827,6 +860,37 @@ test('falls back for invalid immersion tracking tuning values', () => {
assert.ok(
warnings.some((warning) => warning.path === 'immersionTracking.retention.vacuumIntervalDays'),
);
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.retentionMode'));
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.retentionPreset'));
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.lifetimeSummaries'));
});
test('applies retention presets and explicit overrides', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"immersionTracking": {
"retentionMode": "preset",
"retentionPreset": "minimal",
"retention": {
"eventsDays": 11,
"sessionsDays": 8
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.immersionTracking.retentionMode, 'preset');
assert.equal(config.immersionTracking.retentionPreset, 'minimal');
assert.equal(config.immersionTracking.retention.eventsDays, 11);
assert.equal(config.immersionTracking.retention.sessionsDays, 8);
assert.equal(config.immersionTracking.retention.telemetryDays, 14);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 30);
});
test('parses jsonc and warns/falls back on invalid value', () => {
@@ -1363,15 +1427,16 @@ test('runtime options registry is centralized', () => {
]);
});
test('validates ankiConnect n+1 behavior values', () => {
test('validates ankiConnect knownWords behavior values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"knownWords": {
"highlightEnabled": "yes",
"refreshMinutes": -5
"refreshMinutes": -5,
"addMinedWordsImmediately": "no"
}
}
}`,
@@ -1383,26 +1448,34 @@ test('validates ankiConnect n+1 behavior values', () => {
const warnings = service.getWarnings();
assert.equal(
config.ankiConnect.nPlusOne.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
config.ankiConnect.knownWords.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
);
assert.equal(
config.ankiConnect.nPlusOne.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
config.ankiConnect.knownWords.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.refreshMinutes'));
assert.equal(
config.ankiConnect.knownWords.addMinedWordsImmediately,
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately,
);
assert.ok(
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.addMinedWordsImmediately'),
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.refreshMinutes'));
});
test('accepts valid ankiConnect n+1 behavior values', () => {
test('accepts valid ankiConnect knownWords behavior values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"knownWords": {
"highlightEnabled": true,
"refreshMinutes": 120
"refreshMinutes": 120,
"addMinedWordsImmediately": false
}
}
}`,
@@ -1412,8 +1485,9 @@ test('accepts valid ankiConnect n+1 behavior values', () => {
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 120);
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 120);
assert.equal(config.ankiConnect.knownWords.addMinedWordsImmediately, false);
});
test('validates ankiConnect n+1 minimum sentence word count', () => {
@@ -1461,13 +1535,13 @@ test('accepts valid ankiConnect n+1 minimum sentence word count', () => {
assert.equal(config.ankiConnect.nPlusOne.minSentenceWords, 4);
});
test('validates ankiConnect n+1 match mode values', () => {
test('validates ankiConnect knownWords match mode values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"knownWords": {
"matchMode": "bad-mode"
}
}
@@ -1480,19 +1554,19 @@ test('validates ankiConnect n+1 match mode values', () => {
const warnings = service.getWarnings();
assert.equal(
config.ankiConnect.nPlusOne.matchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
config.ankiConnect.knownWords.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.matchMode'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.matchMode'));
});
test('accepts valid ankiConnect n+1 match mode values', () => {
test('accepts valid ankiConnect knownWords match mode values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"knownWords": {
"matchMode": "surface"
}
}
@@ -1503,18 +1577,20 @@ test('accepts valid ankiConnect n+1 match mode values', () => {
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface');
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
});
test('validates ankiConnect n+1 color values', () => {
test('validates ankiConnect knownWords and n+1 color values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"nPlusOne": "not-a-color",
"knownWord": 123
"nPlusOne": "not-a-color"
},
"knownWords": {
"color": 123
}
}
}`,
@@ -1526,23 +1602,22 @@ test('validates ankiConnect n+1 color values', () => {
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
assert.equal(
config.ankiConnect.nPlusOne.knownWord,
DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord,
);
assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.knownWord'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
});
test('accepts valid ankiConnect n+1 color values', () => {
test('accepts valid ankiConnect knownWords and n+1 color values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"nPlusOne": "#c6a0f6",
"knownWord": "#a6da95"
"nPlusOne": "#c6a0f6"
},
"knownWords": {
"color": "#a6da95"
}
}
}`,
@@ -1553,7 +1628,49 @@ test('accepts valid ankiConnect n+1 color values', () => {
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
assert.equal(config.ankiConnect.nPlusOne.knownWord, '#a6da95');
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
});
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
"refreshMinutes": 90,
"matchMode": "surface",
"decks": ["Mining", "Kaishi 1.5k"],
"knownWord": "#a6da95"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(config.ankiConnect.knownWords.decks, {
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
assert.ok(
warnings.some(
(warning) =>
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
warning.path === 'ankiConnect.nPlusOne.decks' ||
warning.path === 'ankiConnect.nPlusOne.knownWord',
),
);
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
@@ -1576,9 +1693,9 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 90);
assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface');
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.ok(
warnings.some(
(warning) =>
@@ -1799,14 +1916,14 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
);
});
test('accepts valid ankiConnect n+1 deck list', () => {
test('accepts valid ankiConnect knownWords deck object', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"decks": ["Deck One", "Deck Two"]
"knownWords": {
"decks": { "Deck One": ["Word", "Reading"], "Deck Two": ["Expression"] }
}
}
}`,
@@ -1816,7 +1933,10 @@ test('accepts valid ankiConnect n+1 deck list', () => {
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, ['Deck One', 'Deck Two']);
assert.deepEqual(config.ankiConnect.knownWords.decks, {
'Deck One': ['Word', 'Reading'],
'Deck Two': ['Expression'],
});
});
test('accepts valid ankiConnect tags list', () => {
@@ -1857,13 +1977,13 @@ test('falls back to default when ankiConnect tags list is invalid', () => {
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags'));
});
test('falls back to default when ankiConnect n+1 deck list is invalid', () => {
test('falls back to default when ankiConnect knownWords deck list is invalid', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"knownWords": {
"decks": "not-an-array"
}
}
@@ -1875,8 +1995,8 @@ test('falls back to default when ankiConnect n+1 deck list is invalid', () => {
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, []);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks'));
assert.deepEqual(config.ankiConnect.knownWords.decks, {});
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'));
});
test('template generator includes known keys', () => {
@@ -1891,9 +2011,10 @@ test('template generator includes known keys', () => {
assert.match(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"knownWords"\s*:\s*\{/);
assert.match(output, /"color": "#a6da95"/);
assert.match(output, /"nPlusOne"\s*:\s*\{/);
assert.match(output, /"nPlusOne": "#c6a0f6"/);
assert.match(output, /"knownWord": "#a6da95"/);
assert.match(output, /"minSentenceWords": 3/);
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
assert.match(

View File

@@ -2,10 +2,12 @@ import { RawConfig, ResolvedConfig } from '../types';
import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core';
import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion';
import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations';
import { STATS_DEFAULT_CONFIG } from './definitions/defaults-stats';
import { SUBTITLE_DEFAULT_CONFIG } from './definitions/defaults-subtitle';
import { buildCoreConfigOptionRegistry } from './definitions/options-core';
import { buildImmersionConfigOptionRegistry } from './definitions/options-immersion';
import { buildIntegrationConfigOptionRegistry } from './definitions/options-integrations';
import { buildStatsConfigOptionRegistry } from './definitions/options-stats';
import { buildSubtitleConfigOptionRegistry } from './definitions/options-subtitle';
import { buildRuntimeOptionRegistry } from './definitions/runtime-options';
import { CONFIG_TEMPLATE_SECTIONS } from './definitions/template-sections';
@@ -36,6 +38,7 @@ const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, yo
INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitlePosition,
@@ -60,6 +63,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
ai,
youtubeSubgen,
immersionTracking,
stats,
};
export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
@@ -71,6 +75,7 @@ export const CONFIG_OPTION_REGISTRY = [
...buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
...buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
...buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
...buildStatsConfigOptionRegistry(DEFAULT_CONFIG),
];
export { CONFIG_TEMPLATE_SECTIONS };

View File

@@ -9,12 +9,20 @@ export const IMMERSION_DEFAULT_CONFIG: Pick<ResolvedConfig, 'immersionTracking'>
queueCap: 1000,
payloadCapBytes: 256,
maintenanceIntervalMs: 24 * 60 * 60 * 1000,
retentionMode: 'preset',
retentionPreset: 'balanced',
retention: {
eventsDays: 7,
telemetryDays: 30,
dailyRollupsDays: 365,
monthlyRollupsDays: 5 * 365,
vacuumIntervalDays: 7,
eventsDays: 0,
telemetryDays: 0,
sessionsDays: 0,
dailyRollupsDays: 0,
monthlyRollupsDays: 0,
vacuumIntervalDays: 0,
},
lifetimeSummaries: {
global: true,
anime: true,
media: true,
},
},
};

View File

@@ -23,6 +23,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
},
tags: ['SubMiner'],
fields: {
word: 'Expression',
audio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',
@@ -46,10 +47,19 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
animatedMaxWidth: 640,
animatedMaxHeight: undefined,
animatedCrf: 35,
syncAnimatedImageToWordAudio: true,
audioPadding: 0.5,
fallbackDuration: 3.0,
maxMediaDuration: 30,
},
knownWords: {
highlightEnabled: false,
refreshMinutes: 1440,
addMinedWordsImmediately: true,
matchMode: 'headword',
decks: {},
color: '#a6da95',
},
behavior: {
overwriteAudio: true,
overwriteImage: true,
@@ -59,13 +69,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
autoUpdateNewCards: true,
},
nPlusOne: {
highlightEnabled: false,
refreshMinutes: 1440,
matchMode: 'headword',
decks: [],
minSentenceWords: 3,
nPlusOne: '#c6a0f6',
knownWord: '#a6da95',
},
metadata: {
pattern: '[SubMiner] %f (%t)',

View File

@@ -0,0 +1,11 @@
import { ResolvedConfig } from '../../types.js';
export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
stats: {
toggleKey: 'Backquote',
markWatchedKey: 'KeyW',
serverPort: 6969,
autoStartServer: true,
autoOpenBrowser: true,
},
};

View File

@@ -48,35 +48,73 @@ export function buildImmersionConfigOptionRegistry(
defaultValue: defaultConfig.immersionTracking.maintenanceIntervalMs,
description: 'Maintenance cadence (prune + rollup + vacuum checks).',
},
{
path: 'immersionTracking.retentionMode',
kind: 'string',
defaultValue: defaultConfig.immersionTracking.retentionMode,
description: 'Retention mode (`preset` uses preset values, `advanced` uses explicit values).',
enumValues: ['preset', 'advanced'],
},
{
path: 'immersionTracking.retentionPreset',
kind: 'string',
defaultValue: defaultConfig.immersionTracking.retentionPreset,
description: 'Retention preset when `retentionMode` is `preset`.',
enumValues: ['minimal', 'balanced', 'deep-history'],
},
{
path: 'immersionTracking.retention.eventsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.eventsDays,
description: 'Raw event retention window in days.',
description: 'Raw event retention window in days. Use 0 to keep all.',
},
{
path: 'immersionTracking.retention.telemetryDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.telemetryDays,
description: 'Telemetry retention window in days.',
description: 'Telemetry retention window in days. Use 0 to keep all.',
},
{
path: 'immersionTracking.retention.sessionsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.sessionsDays,
description: 'Session retention window in days. Use 0 to keep all.',
},
{
path: 'immersionTracking.retention.dailyRollupsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.dailyRollupsDays,
description: 'Daily rollup retention window in days.',
description: 'Daily rollup retention window in days. Use 0 to keep all.',
},
{
path: 'immersionTracking.retention.monthlyRollupsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.monthlyRollupsDays,
description: 'Monthly rollup retention window in days.',
description: 'Monthly rollup retention window in days. Use 0 to keep all.',
},
{
path: 'immersionTracking.retention.vacuumIntervalDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.vacuumIntervalDays,
description: 'Minimum days between VACUUM runs.',
description: 'Minimum days between VACUUM runs. Use 0 to disable.',
},
{
path: 'immersionTracking.lifetimeSummaries.global',
kind: 'boolean',
defaultValue: defaultConfig.immersionTracking.lifetimeSummaries?.global,
description: 'Maintain global lifetime stats rows.',
},
{
path: 'immersionTracking.lifetimeSummaries.anime',
kind: 'boolean',
defaultValue: defaultConfig.immersionTracking.lifetimeSummaries?.anime,
description: 'Maintain per-anime lifetime stats rows.',
},
{
path: 'immersionTracking.lifetimeSummaries.media',
kind: 'boolean',
defaultValue: defaultConfig.immersionTracking.lifetimeSummaries?.media,
description: 'Maintain per-media lifetime stats rows.',
},
];
}

View File

@@ -51,6 +51,12 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
},
{
path: 'ankiConnect.fields.word',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.word,
description: 'Card field for the mined word or expression text.',
},
{
path: 'ankiConnect.ai.enabled',
kind: 'boolean',
@@ -77,24 +83,37 @@ export function buildIntegrationConfigOptionRegistry(
runtime: runtimeOptionById.get('anki.autoUpdateNewCards'),
},
{
path: 'ankiConnect.nPlusOne.matchMode',
kind: 'enum',
enumValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode,
description: 'Known-word matching strategy for N+1 highlighting.',
path: 'ankiConnect.media.syncAnimatedImageToWordAudio',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.media.syncAnimatedImageToWordAudio,
description:
'For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio.',
},
{
path: 'ankiConnect.nPlusOne.highlightEnabled',
path: 'ankiConnect.knownWords.matchMode',
kind: 'enum',
enumValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.knownWords.matchMode,
description: 'Known-word matching strategy for subtitle annotations.',
},
{
path: 'ankiConnect.knownWords.highlightEnabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled,
defaultValue: defaultConfig.ankiConnect.knownWords.highlightEnabled,
description: 'Enable fast local highlighting for words already known in Anki.',
},
{
path: 'ankiConnect.nPlusOne.refreshMinutes',
path: 'ankiConnect.knownWords.refreshMinutes',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.nPlusOne.refreshMinutes,
defaultValue: defaultConfig.ankiConnect.knownWords.refreshMinutes,
description: 'Minutes between known-word cache refreshes.',
},
{
path: 'ankiConnect.knownWords.addMinedWordsImmediately',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
description: 'Immediately append newly mined card words into the known-word cache.',
},
{
path: 'ankiConnect.nPlusOne.minSentenceWords',
kind: 'number',
@@ -102,10 +121,11 @@ export function buildIntegrationConfigOptionRegistry(
description: 'Minimum sentence word count required for N+1 targeting (default: 3).',
},
{
path: 'ankiConnect.nPlusOne.decks',
kind: 'array',
defaultValue: defaultConfig.ankiConnect.nPlusOne.decks,
description: 'Decks used for N+1 known-word cache scope. Supports one or more deck names.',
path: 'ankiConnect.knownWords.decks',
kind: 'object',
defaultValue: defaultConfig.ankiConnect.knownWords.decks,
description:
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
},
{
path: 'ankiConnect.nPlusOne.nPlusOne',
@@ -114,10 +134,10 @@ export function buildIntegrationConfigOptionRegistry(
description: 'Color used for the single N+1 target token highlight.',
},
{
path: 'ankiConnect.nPlusOne.knownWord',
path: 'ankiConnect.knownWords.color',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.nPlusOne.knownWord,
description: 'Color used for legacy known-word highlights.',
defaultValue: defaultConfig.ankiConnect.knownWords.color,
description: 'Color used for known-word highlights.',
},
{
path: 'ankiConnect.isKiku.fieldGrouping',

View File

@@ -0,0 +1,39 @@
import { ResolvedConfig } from '../../types.js';
import { ConfigOptionRegistryEntry } from './shared.js';
export function buildStatsConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
{
path: 'stats.toggleKey',
kind: 'string',
defaultValue: defaultConfig.stats.toggleKey,
description: 'Key code to toggle the stats overlay.',
},
{
path: 'stats.markWatchedKey',
kind: 'string',
defaultValue: defaultConfig.stats.markWatchedKey,
description: 'Key code to mark the current video as watched and advance to the next playlist entry.',
},
{
path: 'stats.serverPort',
kind: 'number',
defaultValue: defaultConfig.stats.serverPort,
description: 'Port for the stats HTTP server.',
},
{
path: 'stats.autoStartServer',
kind: 'boolean',
defaultValue: defaultConfig.stats.autoStartServer,
description: 'Automatically start the stats server on launch.',
},
{
path: 'stats.autoOpenBrowser',
kind: 'boolean',
defaultValue: defaultConfig.stats.autoOpenBrowser,
description: 'Automatically open the stats dashboard in a browser when the server starts.',
},
];
}

View File

@@ -21,15 +21,19 @@ export function buildRuntimeOptionRegistry(
},
{
id: 'subtitle.annotation.nPlusOne',
path: 'ankiConnect.nPlusOne.highlightEnabled',
path: 'ankiConnect.knownWords.highlightEnabled',
label: 'N+1 Annotation',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled,
defaultValue: defaultConfig.ankiConnect.knownWords.highlightEnabled,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: () => ({}),
toAnkiPatch: (value) => ({
knownWords: {
highlightEnabled: value === true,
},
}),
},
{
id: 'subtitle.annotation.jlpt',
@@ -57,16 +61,16 @@ export function buildRuntimeOptionRegistry(
},
{
id: 'anki.nPlusOneMatchMode',
path: 'ankiConnect.nPlusOne.matchMode',
label: 'N+1 Match Mode',
path: 'ankiConnect.knownWords.matchMode',
label: 'Known Word Match Mode',
scope: 'ankiConnect',
valueType: 'enum',
allowedValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode,
defaultValue: defaultConfig.ankiConnect.knownWords.matchMode,
requiresRestart: false,
formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({
nPlusOne: {
knownWords: {
matchMode: value === 'headword' || value === 'surface' ? value : 'headword',
},
}),

View File

@@ -176,6 +176,14 @@ const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'immersionTracking',
},
{
title: 'Stats Dashboard',
description: [
'Local immersion stats dashboard served on localhost and available as an in-app overlay.',
'Uses the immersion tracking database for overview, trends, sessions, and vocabulary views.',
],
key: 'stats',
},
];
export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [

View File

@@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context';
import { applyCoreDomainConfig } from './resolve/core-domains';
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
import { applyIntegrationConfig } from './resolve/integrations';
import { applyStatsConfig } from './resolve/stats';
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
import { applyTopLevelConfig } from './resolve/top-level';
@@ -13,6 +14,7 @@ const APPLY_RESOLVE_STEPS = [
applySubtitleDomainConfig,
applyIntegrationConfig,
applyImmersionTrackingConfig,
applyStatsConfig,
applyAnkiConnectResolution,
] as const;

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.',
);
}
}