Harden stats APIs and fix Electron Yomitan debug runtime

- Validate stats session IDs/limits and add AnkiConnect request timeouts
- Stabilize stats window/runtime lifecycle and tighten window security defaults
- Fix Electron CLI debug startup by unsetting `ELECTRON_RUN_AS_NODE` and wiring Yomitan session state
- Expand regression coverage for tracker queries/events ordering and session aggregates
- Update docs for stats dashboard usage and Yomitan lookup troubleshooting
This commit is contained in:
2026-03-15 12:26:29 -07:00
parent 93811ebfde
commit 46fbea902a
16 changed files with 401 additions and 90 deletions

View File

@@ -618,8 +618,8 @@ test('monthly rollups are grouped by calendar month', async () => {
runRollupMaintenance: () => void;
};
const januaryStartedAtMs = Date.UTC(2026, 0, 31, 23, 59, 59, 0);
const februaryStartedAtMs = Date.UTC(2026, 1, 1, 0, 0, 1, 0);
const januaryStartedAtMs = Date.UTC(2026, 0, 15, 12, 0, 0, 0);
const februaryStartedAtMs = Date.UTC(2026, 1, 15, 12, 0, 0, 0);
privateApi.db.exec(`
INSERT INTO imm_videos (

View File

@@ -109,7 +109,13 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => {
assert.equal(row.sessionId, sessionId);
assert.equal(row.canonicalTitle, 'Query Test Episode');
assert.equal(row.videoId, videoId);
assert.ok(row.linesSeen >= 5);
assert.equal(row.linesSeen, 5);
assert.equal(row.totalWatchedMs, 3_000);
assert.equal(row.activeWatchedMs, 2_500);
assert.equal(row.wordsSeen, 10);
assert.equal(row.tokensSeen, 10);
assert.equal(row.lookupCount, 2);
assert.equal(row.lookupHits, 1);
} finally {
db.close();
cleanupDbPath(dbPath);
@@ -137,7 +143,12 @@ test('getSessionSummaries with no telemetry returns zero aggregates', () => {
assert.ok(row, 'expected to find the session with no telemetry');
assert.equal(row.canonicalTitle, 'No Telemetry');
assert.equal(row.totalWatchedMs, 0);
assert.equal(row.activeWatchedMs, 0);
assert.equal(row.linesSeen, 0);
assert.equal(row.wordsSeen, 0);
assert.equal(row.tokensSeen, 0);
assert.equal(row.lookupCount, 0);
assert.equal(row.lookupHits, 0);
assert.equal(row.cardsMined, 0);
} finally {
db.close();
@@ -153,13 +164,19 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
// Insert words: 猫 twice, 犬 once
// Insert words with the highest-frequency entry inserted after another word
stmts.wordUpsertStmt.run('犬', '犬', 'いぬ', 'noun', '名詞', '一般', '', 1_500, 1_500);
stmts.wordUpsertStmt.run('猫', '猫', 'ねこ', 'noun', '名詞', '一般', '', 1_000, 2_000);
stmts.wordUpsertStmt.run('猫', '猫', 'ねこ', 'noun', '名詞', '一般', '', 1_000, 3_000);
stmts.wordUpsertStmt.run('犬', '犬', 'いぬ', 'noun', '名詞', '一般', '', 1_500, 1_500);
const rows = getVocabularyStats(db, 10);
assert.equal(rows.length, 2);
assert.equal(rows[0]?.headword, '猫');
assert.equal(rows[1]?.headword, '犬');
assert.equal(rows[0]?.frequency, 2);
assert.equal(rows[1]?.frequency, 1);
assert.ok(rows.length >= 2);
// First row should be 猫 (frequency 2)
const nekRow = rows.find((r) => r.headword === '猫');
@@ -432,13 +449,17 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
// Insert kanji: 日 twice, 月 once
// Insert kanji with highest-frequency entry inserted after another character
stmts.kanjiUpsertStmt.run('月', 1_500, 1_500);
stmts.kanjiUpsertStmt.run('日', 1_000, 2_000);
stmts.kanjiUpsertStmt.run('日', 1_000, 3_000);
stmts.kanjiUpsertStmt.run('月', 1_500, 1_500);
const rows = getKanjiStats(db, 10);
assert.equal(rows.length, 2);
assert.equal(rows[0]?.kanji, '日');
assert.equal(rows[1]?.kanji, '月');
assert.ok(rows.length >= 2);
const nichiRow = rows.find((r) => r.kanji === '日');
const tsukiRow = rows.find((r) => r.kanji === '月');
@@ -539,7 +560,16 @@ test('getSessionEvents returns empty array for session with no events', () => {
try {
ensureSchema(db);
const events = getSessionEvents(db, 9999, 50);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-empty.mkv', {
canonicalTitle: 'Events Empty',
sourcePath: '/tmp/events-empty.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, 6_000_000);
const events = getSessionEvents(db, sessionId, 50);
assert.deepEqual(events, []);
} finally {
db.close();
@@ -547,6 +577,72 @@ test('getSessionEvents returns empty array for session with no events', () => {
}
});
test('getSessionEvents filters events to the requested session id', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const decoyVideoId = getOrCreateVideoRecord(db, 'local:/tmp/events-filter-decoy.mkv', {
canonicalTitle: 'Events Filter Decoy',
sourcePath: '/tmp/events-filter-decoy.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const targetVideoId = getOrCreateVideoRecord(db, 'local:/tmp/events-filter-target.mkv', {
canonicalTitle: 'Events Filter Target',
sourcePath: '/tmp/events-filter-target.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const decoySession = startSessionRecord(db, decoyVideoId, 8_000_000);
const targetSession = startSessionRecord(db, targetVideoId, 8_100_000);
// Decoy session event
stmts.eventInsertStmt.run(
decoySession.sessionId,
8_100_000 + 1,
EVENT_SUBTITLE_LINE,
1,
0,
500,
1,
0,
'{"line":"decoy"}',
8_100_000 + 1,
8_100_000 + 1,
);
// Target session event
stmts.eventInsertStmt.run(
targetSession.sessionId,
8_100_000 + 2,
EVENT_SUBTITLE_LINE,
2,
0,
600,
1,
0,
'{"line":"target"}',
8_100_000 + 2,
8_100_000 + 2,
);
const events = getSessionEvents(db, targetSession.sessionId, 50);
assert.equal(events.length, 1);
assert.equal(events[0]?.payload, '{"line":"target"}');
assert.equal(events[0]?.eventType, EVENT_SUBTITLE_LINE);
assert.equal(events[0]?.tsMs, 8100002);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getSessionEvents respects limit parameter', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);

View File

@@ -229,6 +229,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return Math.min(value as number, maxValue);
};
const parsePositiveInteger = (value: unknown): number | null => {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
return null;
}
return value;
};
ipc.on(
IPC_CHANNELS.command.setIgnoreMouseEvents,
(event: unknown, ignore: unknown, options: unknown = {}) => {
@@ -474,18 +481,20 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
ipc.handle(
IPC_CHANNELS.request.statsGetSessionTimeline,
async (_event, sessionId: unknown, limit: unknown) => {
if (typeof sessionId !== 'number') return [];
const parsedSessionId = parsePositiveInteger(sessionId);
if (parsedSessionId === null) return [];
const parsedLimit = parsePositiveIntLimit(limit, 200, 1000);
return deps.immersionTracker?.getSessionTimeline(sessionId, parsedLimit) ?? [];
return deps.immersionTracker?.getSessionTimeline(parsedSessionId, parsedLimit) ?? [];
},
);
ipc.handle(
IPC_CHANNELS.request.statsGetSessionEvents,
async (_event, sessionId: unknown, limit: unknown) => {
if (typeof sessionId !== 'number') return [];
const parsedSessionId = parsePositiveInteger(sessionId);
if (parsedSessionId === null) return [];
const parsedLimit = parsePositiveIntLimit(limit, 500, 1000);
return deps.immersionTracker?.getSessionEvents(sessionId, parsedLimit) ?? [];
return deps.immersionTracker?.getSessionEvents(parsedSessionId, parsedLimit) ?? [];
},
);

View File

@@ -37,6 +37,7 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null {
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
@@ -130,7 +131,7 @@ export function createStatsApp(
});
app.get('/api/stats/sessions/:id/timeline', async (c) => {
const id = parseIntQuery(c.req.query('id') ?? c.req.param('id'), 0);
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json([], 400);
const limit = parseIntQuery(c.req.query('limit'), 200, 1000);
const timeline = await tracker.getSessionTimeline(id, limit);
@@ -138,7 +139,7 @@ export function createStatsApp(
});
app.get('/api/stats/sessions/:id/events', async (c) => {
const id = parseIntQuery(c.req.query('id') ?? c.req.param('id'), 0);
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json([], 400);
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
const events = await tracker.getSessionEvents(id, limit);
@@ -304,6 +305,7 @@ export function createStatsApp(
const response = await fetch('http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
body: JSON.stringify({ action: 'guiBrowse', version: 6, params: { query: `nid:${noteId}` } }),
});
const result = await response.json();
@@ -315,12 +317,17 @@ export function createStatsApp(
app.post('/api/stats/anki/notesInfo', async (c) => {
const body = await c.req.json().catch(() => null);
const noteIds = Array.isArray(body?.noteIds) ? body.noteIds.filter((id: unknown) => typeof id === 'number') : [];
const noteIds = Array.isArray(body?.noteIds)
? body.noteIds.filter(
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
)
: [];
if (noteIds.length === 0) return c.json([]);
try {
const response = await fetch('http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: noteIds } }),
});
const result = await response.json() as { result?: Array<{ noteId: number; fields: Record<string, { value: string }> }> };

View File

@@ -36,7 +36,7 @@ export function buildStatsWindowOptions(options: {
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
frame: false,
transparent: false,
transparent: true,
alwaysOnTop: true,
resizable: false,
skipTaskbar: true,
@@ -50,7 +50,7 @@ export function buildStatsWindowOptions(options: {
nodeIntegration: false,
contextIsolation: true,
preload: options.preloadPath,
sandbox: false,
sandbox: true,
},
};
}

View File

@@ -27,7 +27,7 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w
assert.equal(options.webPreferences?.preload, '/tmp/preload-stats.js');
assert.equal(options.webPreferences?.contextIsolation, true);
assert.equal(options.webPreferences?.nodeIntegration, false);
assert.equal(options.webPreferences?.sandbox, false);
assert.equal(options.webPreferences?.sandbox, true);
});
test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
@@ -55,6 +55,58 @@ test('shouldHideStatsWindowForInput matches Escape and configured bare toggle ke
true,
);
assert.equal(
shouldHideStatsWindowForInput(
{
type: 'keyDown',
key: '`',
code: 'Backquote',
control: true,
} as Electron.Input,
'Backquote',
),
false,
);
assert.equal(
shouldHideStatsWindowForInput(
{
type: 'keyDown',
key: '`',
code: 'Backquote',
alt: true,
} as Electron.Input,
'Backquote',
),
false,
);
assert.equal(
shouldHideStatsWindowForInput(
{
type: 'keyDown',
key: '`',
code: 'Backquote',
meta: true,
} as Electron.Input,
'Backquote',
),
false,
);
assert.equal(
shouldHideStatsWindowForInput(
{
type: 'keyDown',
key: '`',
code: 'Backquote',
isAutoRepeat: true,
} as Electron.Input,
'Backquote',
),
false,
);
assert.equal(
shouldHideStatsWindowForInput(
{