mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-27 06:12:05 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)',
|
||||
|
||||
11
src/config/definitions/defaults-stats.ts
Normal file
11
src/config/definitions/defaults-stats.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
39
src/config/definitions/options-stats.ts
Normal file
39
src/config/definitions/options-stats.ts
Normal 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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
53
src/config/resolve/stats.ts
Normal file
53
src/config/resolve/stats.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user