mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
fix: harden AnkiConnect resolver and stats-server route validation
- Warn and fall back for invalid primitive values across modern/legacy AnkiConnect config subtrees - Validate AniList ID as positive integer; add timeout to AniList search fetch - Reject non-safe or fractional session IDs; return 404 for malformed percent-encoded asset paths - Guard word-mode mining: require non-empty word and Yomitan bridge before starting media work - Include note ID in mined media filenames to prevent collisions - Silence throwing timing observers so they cannot abort mining execution - Trim whitespace from excludePos query param entries
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Validated nested and legacy AnkiConnect settings after splitting the resolver, preserving valid modern overrides while warning and falling back for invalid primitive values.
|
||||||
|
- Hardened stats routes against malformed IDs and static paths, stalled AniList searches, word-mining media collisions, missing Yomitan bridges, and throwing timing observers.
|
||||||
@@ -112,7 +112,16 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
|||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const valueNode = propertyValue(property);
|
const valueNode = propertyValue(property);
|
||||||
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
||||||
if (key === 'enabled' || key === 'minSentenceWords') {
|
if (key === 'enabled' && typeof value === 'boolean') {
|
||||||
|
canonicalNPlusOneValues.set(key, value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key === 'minSentenceWords' &&
|
||||||
|
typeof value === 'number' &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value > 0
|
||||||
|
) {
|
||||||
canonicalNPlusOneValues.set(key, value);
|
canonicalNPlusOneValues.set(key, value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,74 @@ test('modern invalid knownWords.highlightEnabled warns modern key and does not f
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('invalid modern known-words primitive values warn and keep defaults', () => {
|
||||||
|
const { context, warnings } = makeContext({
|
||||||
|
knownWords: {
|
||||||
|
refreshMinutes: 'daily',
|
||||||
|
matchMode: false,
|
||||||
|
},
|
||||||
|
nPlusOne: {
|
||||||
|
minSentenceWords: 'three',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applyAnkiConnectResolution(context);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
[
|
||||||
|
'ankiConnect.knownWords.refreshMinutes',
|
||||||
|
'ankiConnect.nPlusOne.minSentenceWords',
|
||||||
|
'ankiConnect.knownWords.matchMode',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid legacy known-words primitive values warn and keep defaults', () => {
|
||||||
|
const { context, warnings } = makeContext({
|
||||||
|
behavior: {
|
||||||
|
nPlusOneHighlightEnabled: 'yes',
|
||||||
|
nPlusOneRefreshMinutes: 'daily',
|
||||||
|
nPlusOneMatchMode: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applyAnkiConnectResolution(context);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.highlightEnabled,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
[
|
||||||
|
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||||
|
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||||
|
'ankiConnect.behavior.nPlusOneMatchMode',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('known-words resolution can run independently from other Anki domains', () => {
|
test('known-words resolution can run independently from other Anki domains', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
knownWords: { highlightEnabled: true },
|
knownWords: { highlightEnabled: true },
|
||||||
@@ -194,6 +262,40 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('invalid modern Anki subtrees warn and keep resolved defaults', () => {
|
||||||
|
const { context, warnings } = makeContext({
|
||||||
|
fields: { word: 7 },
|
||||||
|
media: { generateAudio: 'yes' },
|
||||||
|
behavior: { overwriteAudio: 'yes' },
|
||||||
|
metadata: { pattern: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
applyAnkiConnectResolution(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.ankiConnect.fields.word, DEFAULT_CONFIG.ankiConnect.fields.word);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.media.generateAudio,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.generateAudio,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.behavior.overwriteAudio,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.behavior.overwriteAudio,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.metadata.pattern,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.metadata.pattern,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
[
|
||||||
|
'ankiConnect.fields.word',
|
||||||
|
'ankiConnect.media.generateAudio',
|
||||||
|
'ankiConnect.behavior.overwriteAudio',
|
||||||
|
'ankiConnect.metadata.pattern',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => {
|
test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
wordField: 'TargetWordLegacy',
|
wordField: 'TargetWordLegacy',
|
||||||
|
|||||||
@@ -52,33 +52,21 @@ export function initializeAnkiConnectResolution(
|
|||||||
: {}),
|
: {}),
|
||||||
fields: {
|
fields: {
|
||||||
...context.resolved.ankiConnect.fields,
|
...context.resolved.ankiConnect.fields,
|
||||||
...(isObject(ankiConnect.fields)
|
|
||||||
? (ankiConnect.fields as (typeof context.resolved)['ankiConnect']['fields'])
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
media: {
|
media: {
|
||||||
...context.resolved.ankiConnect.media,
|
...context.resolved.ankiConnect.media,
|
||||||
...(isObject(ankiConnect.media)
|
|
||||||
? (ankiConnect.media as (typeof context.resolved)['ankiConnect']['media'])
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
knownWords: {
|
knownWords: {
|
||||||
...context.resolved.ankiConnect.knownWords,
|
...context.resolved.ankiConnect.knownWords,
|
||||||
},
|
},
|
||||||
behavior: {
|
behavior: {
|
||||||
...context.resolved.ankiConnect.behavior,
|
...context.resolved.ankiConnect.behavior,
|
||||||
...(isObject(ankiConnect.behavior)
|
|
||||||
? (ankiConnect.behavior as (typeof context.resolved)['ankiConnect']['behavior'])
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
...context.resolved.ankiConnect.proxy,
|
...context.resolved.ankiConnect.proxy,
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
...context.resolved.ankiConnect.metadata,
|
...context.resolved.ankiConnect.metadata,
|
||||||
...(isObject(ankiConnect.metadata)
|
|
||||||
? (ankiConnect.metadata as (typeof context.resolved)['ankiConnect']['metadata'])
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
isLapis: {
|
isLapis: {
|
||||||
...context.resolved.ankiConnect.isLapis,
|
...context.resolved.ankiConnect.isLapis,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_CONFIG } from '../../definitions';
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
import type { ResolveContext } from '../context';
|
import type { ResolveContext } from '../context';
|
||||||
import { asBoolean, asColor, asNumber, asString, isObject } from '../shared';
|
import { asBoolean, asColor, asNumber, asString, isObject } from '../shared';
|
||||||
|
import { hasOwn } from './shared';
|
||||||
|
|
||||||
export function applyAnkiKnownWordsResolution(
|
export function applyAnkiKnownWordsResolution(
|
||||||
context: ResolveContext,
|
context: ResolveContext,
|
||||||
@@ -13,7 +14,7 @@ export function applyAnkiKnownWordsResolution(
|
|||||||
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
||||||
if (knownWordsHighlightEnabled !== undefined) {
|
if (knownWordsHighlightEnabled !== undefined) {
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
|
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
|
||||||
} else if (knownWordsConfig.highlightEnabled !== undefined) {
|
} else if (hasOwn(knownWordsConfig, 'highlightEnabled')) {
|
||||||
context.warn(
|
context.warn(
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
'ankiConnect.knownWords.highlightEnabled',
|
||||||
knownWordsConfig.highlightEnabled,
|
knownWordsConfig.highlightEnabled,
|
||||||
@@ -33,6 +34,15 @@ export function applyAnkiKnownWordsResolution(
|
|||||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
|
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
|
||||||
);
|
);
|
||||||
|
} else if (hasOwn(behavior, 'nPlusOneHighlightEnabled')) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||||
|
behavior.nPlusOneHighlightEnabled,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||||
} else {
|
} else {
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||||
@@ -44,7 +54,7 @@ export function applyAnkiKnownWordsResolution(
|
|||||||
knownWordsRefreshMinutes !== undefined &&
|
knownWordsRefreshMinutes !== undefined &&
|
||||||
Number.isInteger(knownWordsRefreshMinutes) &&
|
Number.isInteger(knownWordsRefreshMinutes) &&
|
||||||
knownWordsRefreshMinutes > 0;
|
knownWordsRefreshMinutes > 0;
|
||||||
if (knownWordsRefreshMinutes !== undefined) {
|
if (hasOwn(knownWordsConfig, 'refreshMinutes')) {
|
||||||
if (hasValidKnownWordsRefreshMinutes) {
|
if (hasValidKnownWordsRefreshMinutes) {
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
|
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
|
||||||
} else {
|
} else {
|
||||||
@@ -57,7 +67,7 @@ export function applyAnkiKnownWordsResolution(
|
|||||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||||
}
|
}
|
||||||
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
|
} else if (hasOwn(behavior, 'nPlusOneRefreshMinutes')) {
|
||||||
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
|
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
|
||||||
const hasValidLegacyRefreshMinutes =
|
const hasValidLegacyRefreshMinutes =
|
||||||
legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
|
legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
|
||||||
@@ -124,7 +134,7 @@ export function applyAnkiKnownWordsResolution(
|
|||||||
nPlusOneMinSentenceWords !== undefined &&
|
nPlusOneMinSentenceWords !== undefined &&
|
||||||
Number.isInteger(nPlusOneMinSentenceWords) &&
|
Number.isInteger(nPlusOneMinSentenceWords) &&
|
||||||
nPlusOneMinSentenceWords > 0;
|
nPlusOneMinSentenceWords > 0;
|
||||||
if (nPlusOneMinSentenceWords !== undefined) {
|
if (hasOwn(nPlusOneConfig, 'minSentenceWords')) {
|
||||||
if (hasValidNPlusOneMinSentenceWords) {
|
if (hasValidNPlusOneMinSentenceWords) {
|
||||||
context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords;
|
context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords;
|
||||||
} else {
|
} else {
|
||||||
@@ -150,7 +160,7 @@ export function applyAnkiKnownWordsResolution(
|
|||||||
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
|
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
|
||||||
if (hasValidKnownWordsMatchMode) {
|
if (hasValidKnownWordsMatchMode) {
|
||||||
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
|
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
|
||||||
} else if (knownWordsMatchMode !== undefined) {
|
} else if (hasOwn(knownWordsConfig, 'matchMode')) {
|
||||||
context.warn(
|
context.warn(
|
||||||
'ankiConnect.knownWords.matchMode',
|
'ankiConnect.knownWords.matchMode',
|
||||||
knownWordsConfig.matchMode,
|
knownWordsConfig.matchMode,
|
||||||
@@ -159,7 +169,7 @@ export function applyAnkiKnownWordsResolution(
|
|||||||
);
|
);
|
||||||
context.resolved.ankiConnect.knownWords.matchMode =
|
context.resolved.ankiConnect.knownWords.matchMode =
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||||
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
|
} else if (hasOwn(behavior, 'nPlusOneMatchMode')) {
|
||||||
if (hasValidLegacyMatchMode) {
|
if (hasValidLegacyMatchMode) {
|
||||||
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
|
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
|
||||||
context.warn(
|
context.warn(
|
||||||
|
|||||||
@@ -3,43 +3,281 @@ import type { ResolveContext } from '../context';
|
|||||||
import { asBoolean, asNumber, asString, isObject } from '../shared';
|
import { asBoolean, asNumber, asString, isObject } from '../shared';
|
||||||
import { asNotificationType, hasOwn } from './shared';
|
import { asNotificationType, hasOwn } from './shared';
|
||||||
|
|
||||||
|
function asIntegerInRange(value: unknown, min: number, max: number): number | undefined {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
return parsed !== undefined && Number.isInteger(parsed) && parsed >= min && parsed <= max
|
||||||
|
? parsed
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNonNegativeInteger(value: unknown): number | undefined {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
return parsed !== undefined && Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPositiveNumber(value: unknown): number | undefined {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
return parsed !== undefined && parsed > 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNonNegativeNumber(value: unknown): number | undefined {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
return parsed !== undefined && parsed >= 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyModernValue<T>(
|
||||||
|
context: ResolveContext,
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
parse: (value: unknown) => T | undefined,
|
||||||
|
fallback: T,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
message: string,
|
||||||
|
): void {
|
||||||
|
if (!hasOwn(source, key)) return;
|
||||||
|
const raw = source[key];
|
||||||
|
const parsed = parse(raw);
|
||||||
|
if (parsed === undefined) {
|
||||||
|
apply(fallback);
|
||||||
|
context.warn(path, raw, fallback, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyModernFieldsResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
fields,
|
||||||
|
key,
|
||||||
|
`ankiConnect.fields.${key}`,
|
||||||
|
asString,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.fields[key],
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.fields[key] = value;
|
||||||
|
},
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyModernMediaResolution(context: ResolveContext, media: Record<string, unknown>): void {
|
||||||
|
for (const key of [
|
||||||
|
'generateAudio',
|
||||||
|
'generateImage',
|
||||||
|
'syncAnimatedImageToWordAudio',
|
||||||
|
'normalizeAudio',
|
||||||
|
'mirrorMpvVolume',
|
||||||
|
] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
key,
|
||||||
|
`ankiConnect.media.${key}`,
|
||||||
|
asBoolean,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media[key],
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media[key] = value;
|
||||||
|
},
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'imageType',
|
||||||
|
'ankiConnect.media.imageType',
|
||||||
|
(value) => (value === 'static' || value === 'avif' ? value : undefined),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.imageType,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageType = value;
|
||||||
|
},
|
||||||
|
"Expected 'static' or 'avif'.",
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'imageFormat',
|
||||||
|
'ankiConnect.media.imageFormat',
|
||||||
|
(value) => (value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.imageFormat,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageFormat = value;
|
||||||
|
},
|
||||||
|
"Expected 'jpg', 'png', or 'webp'.",
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'imageQuality',
|
||||||
|
'ankiConnect.media.imageQuality',
|
||||||
|
(value) => asIntegerInRange(value, 1, 100),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.imageQuality,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageQuality = value;
|
||||||
|
},
|
||||||
|
'Expected integer between 1 and 100.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const key of [
|
||||||
|
'imageMaxWidth',
|
||||||
|
'imageMaxHeight',
|
||||||
|
'animatedMaxWidth',
|
||||||
|
'animatedMaxHeight',
|
||||||
|
] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
key,
|
||||||
|
`ankiConnect.media.${key}`,
|
||||||
|
asNonNegativeInteger,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media[key] ?? 0,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media[key] = value;
|
||||||
|
},
|
||||||
|
'Expected non-negative integer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'animatedFps',
|
||||||
|
'ankiConnect.media.animatedFps',
|
||||||
|
(value) => asIntegerInRange(value, 1, 60),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.animatedFps,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.animatedFps = value;
|
||||||
|
},
|
||||||
|
'Expected integer between 1 and 60.',
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'animatedCrf',
|
||||||
|
'ankiConnect.media.animatedCrf',
|
||||||
|
(value) => asIntegerInRange(value, 0, 63),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.animatedCrf,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.animatedCrf = value;
|
||||||
|
},
|
||||||
|
'Expected integer between 0 and 63.',
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'audioPadding',
|
||||||
|
'ankiConnect.media.audioPadding',
|
||||||
|
asNonNegativeNumber,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.audioPadding,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.audioPadding = value;
|
||||||
|
},
|
||||||
|
'Expected non-negative number.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
key,
|
||||||
|
`ankiConnect.media.${key}`,
|
||||||
|
asPositiveNumber,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media[key],
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media[key] = value;
|
||||||
|
},
|
||||||
|
'Expected positive number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyModernBehaviorResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
behavior: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
for (const key of [
|
||||||
|
'overwriteAudio',
|
||||||
|
'overwriteImage',
|
||||||
|
'highlightWord',
|
||||||
|
'autoUpdateNewCards',
|
||||||
|
] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
behavior,
|
||||||
|
key,
|
||||||
|
`ankiConnect.behavior.${key}`,
|
||||||
|
asBoolean,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.behavior[key],
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior[key] = value;
|
||||||
|
},
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
behavior,
|
||||||
|
'mediaInsertMode',
|
||||||
|
'ankiConnect.behavior.mediaInsertMode',
|
||||||
|
(value) => (value === 'append' || value === 'prepend' ? value : undefined),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
|
||||||
|
},
|
||||||
|
"Expected 'append' or 'prepend'.",
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
behavior,
|
||||||
|
'notificationType',
|
||||||
|
'ankiConnect.behavior.notificationType',
|
||||||
|
asNotificationType,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.behavior.notificationType,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.notificationType = value;
|
||||||
|
},
|
||||||
|
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyModernMetadataResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
metadata,
|
||||||
|
'pattern',
|
||||||
|
'ankiConnect.metadata.pattern',
|
||||||
|
asString,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.metadata.pattern,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.metadata.pattern = value;
|
||||||
|
},
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function applyAnkiModernResolution(
|
export function applyAnkiModernResolution(
|
||||||
context: ResolveContext,
|
context: ResolveContext,
|
||||||
ankiConnect: Record<string, unknown>,
|
ankiConnect: Record<string, unknown>,
|
||||||
behavior: Record<string, unknown>,
|
behavior: Record<string, unknown>,
|
||||||
media: Record<string, unknown>,
|
media: Record<string, unknown>,
|
||||||
): void {
|
): void {
|
||||||
if (hasOwn(media, 'mirrorMpvVolume')) {
|
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
|
||||||
const parsed = asBoolean(media.mirrorMpvVolume);
|
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
|
||||||
if (parsed === undefined) {
|
applyModernFieldsResolution(context, fields);
|
||||||
context.resolved.ankiConnect.media.mirrorMpvVolume =
|
applyModernMediaResolution(context, media);
|
||||||
DEFAULT_CONFIG.ankiConnect.media.mirrorMpvVolume;
|
applyModernBehaviorResolution(context, behavior);
|
||||||
context.warn(
|
applyModernMetadataResolution(context, metadata);
|
||||||
'ankiConnect.media.mirrorMpvVolume',
|
|
||||||
media.mirrorMpvVolume,
|
|
||||||
context.resolved.ankiConnect.media.mirrorMpvVolume,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.media.mirrorMpvVolume = parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasOwn(behavior, 'notificationType')) {
|
|
||||||
const parsed = asNotificationType(behavior.notificationType);
|
|
||||||
if (parsed === undefined) {
|
|
||||||
context.resolved.ankiConnect.behavior.notificationType =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.behavior.notificationType;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.behavior.notificationType',
|
|
||||||
behavior.notificationType,
|
|
||||||
context.resolved.ankiConnect.behavior.notificationType,
|
|
||||||
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.behavior.notificationType = parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyLapisResolution(context, ankiConnect);
|
applyLapisResolution(context, ankiConnect);
|
||||||
applyProxyResolution(context, ankiConnect);
|
applyProxyResolution(context, ankiConnect);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createStatsMiningContext } from '../stats-server/mining-support.js';
|
||||||
|
|
||||||
|
test('mining timing observer errors do not replace successful phase results', async () => {
|
||||||
|
const { timeMiningPhase } = createStatsMiningContext({
|
||||||
|
onMiningTiming: () => {
|
||||||
|
throw new Error('observer failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await timeMiningPhase('word', 'test', async () => 42);
|
||||||
|
|
||||||
|
assert.equal(result, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mining timing observer errors do not replace phase errors', async () => {
|
||||||
|
const phaseError = new Error('phase failed');
|
||||||
|
const { timeMiningPhase } = createStatsMiningContext({
|
||||||
|
onMiningTiming: () => {
|
||||||
|
throw new Error('observer failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
timeMiningPhase('word', 'test', async () => {
|
||||||
|
throw phaseError;
|
||||||
|
}),
|
||||||
|
(error: unknown) => error === phaseError,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -900,9 +900,11 @@ describe('stats server API routes', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request('/api/stats/vocabulary?excludePos=particle,auxiliary');
|
const res = await app.request(
|
||||||
|
'/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20',
|
||||||
|
);
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary']]);
|
assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/stats/vocabulary returns POS fields', async () => {
|
it('GET /api/stats/vocabulary returns POS fields', async () => {
|
||||||
@@ -1001,6 +1003,36 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(res.status, 404);
|
assert.equal(res.status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PATCH /api/stats/anime/:animeId/anilist accepts only positive integer AniList ids', async () => {
|
||||||
|
const assignments: Array<{ animeId: number; body: unknown }> = [];
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
reassignAnimeAnilist: async (animeId: number, body: unknown) => {
|
||||||
|
assignments.push({ animeId, body });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) {
|
||||||
|
const res = await app.request('/api/stats/anime/1/anilist', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ anilistId }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400, `accepted invalid AniList id: ${String(anilistId)}`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(assignments, []);
|
||||||
|
|
||||||
|
const body = { anilistId: 21_802, titleRomaji: 'Little Witch Academia' };
|
||||||
|
const res = await app.request('/api/stats/anime/1/anilist', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(assignments, [{ animeId: 1, body }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
|
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anime/1/cover');
|
const res = await app.request('/api/stats/anime/1/cover');
|
||||||
@@ -1348,6 +1380,86 @@ describe('stats server API routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
let yomitanCalls = 0;
|
||||||
|
let mediaCalls = 0;
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
addYomitanNote: async () => {
|
||||||
|
yomitanCalls += 1;
|
||||||
|
return 777;
|
||||||
|
},
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => {
|
||||||
|
mediaCalls += 1;
|
||||||
|
return Buffer.from('audio');
|
||||||
|
},
|
||||||
|
generateScreenshot: async () => {
|
||||||
|
mediaCalls += 1;
|
||||||
|
return Buffer.from('image');
|
||||||
|
},
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: ' ',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(yomitanCalls, 0);
|
||||||
|
assert.equal(mediaCalls, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card does not start word media without a Yomitan bridge', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
let mediaCalls = 0;
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => {
|
||||||
|
mediaCalls += 1;
|
||||||
|
return Buffer.from('audio');
|
||||||
|
},
|
||||||
|
generateScreenshot: async () => {
|
||||||
|
mediaCalls += 1;
|
||||||
|
return Buffer.from('image');
|
||||||
|
},
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 500);
|
||||||
|
assert.equal(mediaCalls, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
|
it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
@@ -2155,7 +2267,7 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
||||||
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
|
||||||
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2279,8 +2391,8 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const fields = updateRequest?.params?.note?.fields ?? {};
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
|
||||||
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.jpg">$/);
|
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.jpg">$/);
|
||||||
assert.equal(fields.ExpressionAudio, undefined);
|
assert.equal(fields.ExpressionAudio, undefined);
|
||||||
assert.equal(fields.SelectionText, undefined);
|
assert.equal(fields.SelectionText, undefined);
|
||||||
});
|
});
|
||||||
@@ -2335,8 +2447,8 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const fields = updateRequest?.params?.note?.fields ?? {};
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
|
||||||
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.avif">$/);
|
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.avif">$/);
|
||||||
assert.equal(fields.ExpressionAudio, undefined);
|
assert.equal(fields.ExpressionAudio, undefined);
|
||||||
assert.equal(fields.SelectionText, undefined);
|
assert.equal(fields.SelectionText, undefined);
|
||||||
});
|
});
|
||||||
@@ -2492,7 +2604,7 @@ Aligned English subtitle
|
|||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
assert.match(
|
assert.match(
|
||||||
updateRequest?.params?.note?.fields?.Voice ?? '',
|
updateRequest?.params?.note?.fields?.Voice ?? '',
|
||||||
/^\[sound:subminer_audio_\d+\.mp3\]$/,
|
/^\[sound:subminer_audio_\d+_12345\.mp3\]$/,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ notesInfoFields: null },
|
{ notesInfoFields: null },
|
||||||
@@ -2580,7 +2692,11 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
||||||
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/);
|
||||||
|
assert.match(
|
||||||
|
updateRequest?.params?.note?.fields?.Picture ?? '',
|
||||||
|
/^<img src="subminer_image_\d+_12345\.jpg">$/,
|
||||||
|
);
|
||||||
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2670,6 +2786,26 @@ Aligned English subtitle
|
|||||||
assert.deepEqual(await res.json(), { ok: true });
|
assert.deepEqual(await res.json(), { ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/stats/sessions rejects non-safe or fractional session ids', async () => {
|
||||||
|
let deleteCalls = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
deleteSessions: async () => {
|
||||||
|
deleteCalls += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/sessions', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"sessionIds":[1.5,1e309,9007199254740992]}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(deleteCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
||||||
@@ -2694,8 +2830,10 @@ Aligned English subtitle
|
|||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let acquireCalls = 0;
|
let acquireCalls = 0;
|
||||||
let recordCalls = 0;
|
let recordCalls = 0;
|
||||||
globalThis.fetch = (async () =>
|
let requestSignal: AbortSignal | null | undefined;
|
||||||
new Response(
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
requestSignal = init?.signal;
|
||||||
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
data: {
|
data: {
|
||||||
Page: {
|
Page: {
|
||||||
@@ -2707,7 +2845,8 @@ Aligned English subtitle
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
|
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
|
||||||
},
|
},
|
||||||
)) as typeof fetch;
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const app = createStatsApp(createMockTracker(), {
|
const app = createStatsApp(createMockTracker(), {
|
||||||
@@ -2725,6 +2864,7 @@ Aligned English subtitle
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(acquireCalls, 1);
|
assert.equal(acquireCalls, 1);
|
||||||
assert.equal(recordCalls, 1);
|
assert.equal(recordCalls, 1);
|
||||||
|
assert.ok(requestSignal instanceof AbortSignal);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
@@ -2923,6 +3063,15 @@ Aligned English subtitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns not found for malformed percent-encoded asset paths', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), { staticDir: dir });
|
||||||
|
const res = await app.request('/assets/%');
|
||||||
|
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('fetches and serves missing cover art on demand', async () => {
|
it('fetches and serves missing cover art on demand', async () => {
|
||||||
let ensureCalls = 0;
|
let ensureCalls = 0;
|
||||||
let hasCover = false;
|
let hasCover = false;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from './route-support.js';
|
} from './route-support.js';
|
||||||
|
|
||||||
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
||||||
|
const ANILIST_FETCH_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
export function registerStatsIntegrationRoutes(
|
export function registerStatsIntegrationRoutes(
|
||||||
app: Hono,
|
app: Hono,
|
||||||
@@ -39,6 +40,7 @@ export function registerStatsIntegrationRoutes(
|
|||||||
const res = await fetch('https://graphql.anilist.co', {
|
const res = await fetch('https://graphql.anilist.co', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(ANILIST_FETCH_TIMEOUT_MS),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query: `query ($search: String!) {
|
query: `query ($search: String!) {
|
||||||
Page(perPage: 10) {
|
Page(perPage: 10) {
|
||||||
@@ -124,7 +126,13 @@ export function registerStatsIntegrationRoutes(
|
|||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (animeId <= 0) return c.body(null, 400);
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
if (!body?.anilistId) return c.body(null, 400);
|
if (
|
||||||
|
typeof body?.anilistId !== 'number' ||
|
||||||
|
!Number.isInteger(body.anilistId) ||
|
||||||
|
body.anilistId <= 0
|
||||||
|
) {
|
||||||
|
return c.body(null, 400);
|
||||||
|
}
|
||||||
await tracker.reassignAnimeAnilist(animeId, body);
|
await tracker.reassignAnimeAnilist(animeId, body);
|
||||||
return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
|
return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ export function registerStatsLibraryRoutes(
|
|||||||
): void {
|
): void {
|
||||||
app.get('/api/stats/vocabulary', async (c) => {
|
app.get('/api/stats/vocabulary', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||||
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
|
const excludePos = c.req
|
||||||
|
.query('excludePos')
|
||||||
|
?.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
const vocab = await tracker.getVocabularyStats(limit, excludePos);
|
const vocab = await tracker.getVocabularyStats(limit, excludePos);
|
||||||
return c.json(statsJson('vocabulary', vocab));
|
return c.json(statsJson('vocabulary', vocab));
|
||||||
});
|
});
|
||||||
@@ -164,7 +168,9 @@ export function registerStatsLibraryRoutes(
|
|||||||
app.delete('/api/stats/sessions', async (c) => {
|
app.delete('/api/stats/sessions', async (c) => {
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const ids = Array.isArray(body?.sessionIds)
|
const ids = Array.isArray(body?.sessionIds)
|
||||||
? body.sessionIds.filter((id: unknown) => typeof id === 'number' && id > 0)
|
? body.sessionIds.filter(
|
||||||
|
(id: unknown): id is number => Number.isSafeInteger(id) && (id as number) > 0,
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
if (ids.length === 0) return c.body(null, 400);
|
if (ids.length === 0) return c.body(null, 400);
|
||||||
await tracker.deleteSessions(ids);
|
await tracker.deleteSessions(ids);
|
||||||
|
|||||||
@@ -43,7 +43,13 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
const rawMode = c.req.query('mode');
|
const rawMode = c.req.query('mode');
|
||||||
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
||||||
|
|
||||||
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
if (
|
||||||
|
!sourcePath ||
|
||||||
|
!sentence ||
|
||||||
|
(mode === 'word' && !word) ||
|
||||||
|
!Number.isFinite(startMs) ||
|
||||||
|
!Number.isFinite(endMs)
|
||||||
|
) {
|
||||||
return c.json(
|
return c.json(
|
||||||
statsJson('mineCard', {
|
statsJson('mineCard', {
|
||||||
error: 'sourcePath, sentence, startMs, and endMs are required',
|
error: 'sourcePath, sentence, startMs, and endMs are required',
|
||||||
@@ -63,6 +69,10 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
if (!ankiConfig) {
|
if (!ankiConfig) {
|
||||||
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
|
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
|
||||||
}
|
}
|
||||||
|
const addYomitanNote = options?.addYomitanNote;
|
||||||
|
if (mode === 'word' && !addYomitanNote) {
|
||||||
|
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500);
|
||||||
|
}
|
||||||
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
|
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
|
||||||
let retimedSecondaryText = '';
|
let retimedSecondaryText = '';
|
||||||
if (mode === 'sentence' && !bodySecondaryText) {
|
if (mode === 'sentence' && !bodySecondaryText) {
|
||||||
@@ -188,15 +198,11 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'word') {
|
if (mode === 'word') {
|
||||||
if (!options?.addYomitanNote) {
|
|
||||||
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
||||||
timeMiningPhase(
|
timeMiningPhase(
|
||||||
'word',
|
'word',
|
||||||
'addYomitanNote',
|
'addYomitanNote',
|
||||||
() => options.addYomitanNote!(word),
|
() => addYomitanNote!(word),
|
||||||
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
|
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
|
||||||
),
|
),
|
||||||
audioPromise,
|
audioPromise,
|
||||||
@@ -269,7 +275,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||||
try {
|
try {
|
||||||
await timeMiningPhase('word', 'uploadAudio', () =>
|
await timeMiningPhase('word', 'uploadAudio', () =>
|
||||||
client.storeMediaFile(audioFilename, audioBuffer),
|
client.storeMediaFile(audioFilename, audioBuffer),
|
||||||
@@ -282,7 +288,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
|
|
||||||
if (imageBuffer) {
|
if (imageBuffer) {
|
||||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||||
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
|
||||||
try {
|
try {
|
||||||
await timeMiningPhase('word', 'uploadImage', () =>
|
await timeMiningPhase('word', 'uploadImage', () =>
|
||||||
client.storeMediaFile(imageFilename, imageBuffer),
|
client.storeMediaFile(imageFilename, imageBuffer),
|
||||||
@@ -402,7 +408,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||||
try {
|
try {
|
||||||
await timeMiningPhase(mode, 'uploadAudio', () =>
|
await timeMiningPhase(mode, 'uploadAudio', () =>
|
||||||
client.storeMediaFile(audioFilename, audioBuffer),
|
client.storeMediaFile(audioFilename, audioBuffer),
|
||||||
@@ -418,7 +424,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
|
|
||||||
if (imageBuffer) {
|
if (imageBuffer) {
|
||||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||||
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
|
||||||
try {
|
try {
|
||||||
await timeMiningPhase(mode, 'uploadImage', () =>
|
await timeMiningPhase(mode, 'uploadImage', () =>
|
||||||
client.storeMediaFile(imageFilename, imageBuffer),
|
client.storeMediaFile(imageFilename, imageBuffer),
|
||||||
|
|||||||
@@ -153,7 +153,11 @@ export function createStatsMiningContext(options?: StatsMiningRouteOptions) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
|
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
|
||||||
options?.onMiningTiming?.(event);
|
try {
|
||||||
|
options?.onMiningTiming?.(event);
|
||||||
|
} catch {
|
||||||
|
// Timing observers must not affect mining execution.
|
||||||
|
}
|
||||||
statsMiningLogger.debug(
|
statsMiningLogger.debug(
|
||||||
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
|
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -225,7 +225,13 @@ export function buildAnkiNotePreview(
|
|||||||
function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null {
|
function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null {
|
||||||
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
|
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
|
||||||
const absoluteStaticDir = resolve(staticDir);
|
const absoluteStaticDir = resolve(staticDir);
|
||||||
const absolutePath = resolve(absoluteStaticDir, decodeURIComponent(normalizedPath));
|
let decodedPath: string;
|
||||||
|
try {
|
||||||
|
decodedPath = decodeURIComponent(normalizedPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const absolutePath = resolve(absoluteStaticDir, decodedPath);
|
||||||
if (
|
if (
|
||||||
absolutePath !== absoluteStaticDir &&
|
absolutePath !== absoluteStaticDir &&
|
||||||
!absolutePath.startsWith(`${absoluteStaticDir}${sep}`)
|
!absolutePath.startsWith(`${absoluteStaticDir}${sep}`)
|
||||||
|
|||||||
@@ -28,9 +28,12 @@ test('stats data uses the shared HTTP contract while native dialogs retain IPC',
|
|||||||
'integration-routes.ts',
|
'integration-routes.ts',
|
||||||
'library-routes.ts',
|
'library-routes.ts',
|
||||||
'mining-routes.ts',
|
'mining-routes.ts',
|
||||||
]
|
].map((filename) => ({
|
||||||
.map((filename) => read(`src/core/services/stats-server/${filename}`))
|
filename,
|
||||||
.join('\n');
|
source: read(`src/core/services/stats-server/${filename}`),
|
||||||
|
}));
|
||||||
assert.match(apiClient, /StatsHttpClient/);
|
assert.match(apiClient, /StatsHttpClient/);
|
||||||
assert.match(statsServerRoutes, /statsJson/);
|
for (const { filename, source } of statsServerRoutes) {
|
||||||
|
assert.match(source, /statsJson/, `${filename} must use the shared HTTP contract`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user