feat(stats): add note ID resolution and session event handling improvements

- Add note ID resolution through merge redirects in stats API
- Build Anki note previews using configured field names
- Add session event helpers for merged note dedup and stable request keys
- Refactor SessionDetail to prevent redundant note info requests
- Add session event popover and API client tests
This commit is contained in:
2026-03-18 02:24:38 -07:00
parent a0015dc75c
commit 97126caf4e
23 changed files with 528 additions and 52 deletions

View File

@@ -898,6 +898,124 @@ describe('stats server API routes', () => {
assert.equal(res.status, 400);
});
it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
const originalFetch = globalThis.fetch;
const requests: unknown[] = [];
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
requests.push(init?.body ? JSON.parse(String(init.body)) : null);
return new Response(
JSON.stringify({
result: [
{
noteId: 222,
fields: {
Expression: { value: '呪い' },
},
},
],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}) as typeof fetch;
try {
const app = createStatsApp(createMockTracker(), {
resolveAnkiNoteId: (noteId) => (noteId === 111 ? 222 : noteId),
});
const res = await app.request('/api/stats/anki/notesInfo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteIds: [111] }),
});
assert.equal(res.status, 200);
assert.deepEqual(requests, [
{
action: 'notesInfo',
version: 6,
params: { notes: [222] },
},
]);
assert.deepEqual(await res.json(), [
{
noteId: 222,
fields: {
Expression: { value: '呪い' },
},
preview: {
word: '呪い',
sentence: '',
translation: '',
},
},
]);
} finally {
globalThis.fetch = originalFetch;
}
});
it('POST /api/stats/anki/notesInfo returns preview fields using configured word and sentence field names', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
result: [
{
noteId: 333,
fields: {
TargetWord: { value: '<span>連れる</span>' },
Quote: { value: '<div>このまま<b>連れてって</b></div>' },
SelectionText: { value: 'to take along' },
},
},
],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)) as typeof fetch;
try {
const app = createStatsApp(createMockTracker(), {
ankiConnectConfig: {
fields: {
word: 'TargetWord',
sentence: 'Quote',
translation: 'SelectionText',
},
},
});
const res = await app.request('/api/stats/anki/notesInfo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteIds: [333] }),
});
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), [
{
noteId: 333,
fields: {
TargetWord: { value: '<span>連れる</span>' },
Quote: { value: '<div>このまま<b>連れてって</b></div>' },
SelectionText: { value: 'to take along' },
},
preview: {
word: '連れる',
sentence: 'このまま 連れてって',
translation: 'to take along',
},
},
]);
} finally {
globalThis.fetch = originalFetch;
}
});
it('serves stats index and asset files from absolute static dir paths', async () => {
await withTempDir(async (dir) => {
const assetDir = path.join(dir, 'assets');

View File

@@ -6,6 +6,12 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
import { MediaGenerator } from '../../media-generator.js';
import { AnkiConnectClient } from '../../anki-connect.js';
import type { AnkiConnectConfig } from '../../types.js';
import {
getConfiguredSentenceFieldName,
getConfiguredTranslationFieldName,
getConfiguredWordFieldName,
getPreferredNoteFieldValue,
} from '../../anki-field-config.js';
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
if (raw === undefined) return fallback;
@@ -25,6 +31,15 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
return raw === 'month' ? 'month' : 'day';
}
function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
if (!raw) return undefined;
const parsed = raw
.split(',')
.map((entry) => Number.parseInt(entry.trim(), 10))
.filter((entry) => Number.isInteger(entry) && entry > 0);
return parsed.length > 0 ? parsed : undefined;
}
/** Load known words cache from disk into a Set. Returns null if unavailable. */
function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
if (!cachePath || !existsSync(cachePath)) return null;
@@ -60,6 +75,7 @@ export interface StatsServerConfig {
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
}
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
@@ -81,6 +97,17 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
};
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
function buildAnkiNotePreview(
fields: Record<string, { value: string }>,
ankiConfig?: Pick<AnkiConnectConfig, 'fields'>,
): { word: string; sentence: string; translation: string } {
return {
word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]),
sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]),
translation: getPreferredNoteFieldValue(fields, [getConfiguredTranslationFieldName(ankiConfig)]),
};
}
function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null {
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
const decodedPath = decodeURIComponent(normalizedPath);
@@ -129,6 +156,7 @@ export function createStatsApp(
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
},
) {
const app = new Hono();
@@ -199,7 +227,8 @@ export function createStatsApp(
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);
const eventTypes = parseEventTypesQuery(c.req.query('types'));
const events = await tracker.getSessionEvents(id, limit, eventTypes);
return c.json(events);
});
@@ -509,23 +538,38 @@ 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)
const noteIds: number[] = 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([]);
const resolvedNoteIds = Array.from(
new Set(
noteIds.map((noteId) => {
const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId);
return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0
? (resolvedNoteId as number)
: noteId;
}),
),
);
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 } }),
body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: resolvedNoteIds } }),
});
const result = (await response.json()) as {
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
};
return c.json(result.result ?? []);
return c.json(
(result.result ?? []).map((note) => ({
...note,
preview: buildAnkiNotePreview(note.fields, options?.ankiConnectConfig),
})),
);
} catch {
return c.json([], 502);
}
@@ -710,6 +754,7 @@ export function createStatsApp(
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
@@ -726,7 +771,7 @@ export function createStatsApp(
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
if (word) {
fields['Expression'] = word;
fields[wordFieldName] = word;
}
if (mode === 'sentence') {
fields['IsSentenceCard'] = 'x';
@@ -831,6 +876,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
mpvSocketPath: config.mpvSocketPath,
ankiConnectConfig: config.ankiConnectConfig,
addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId,
});
const server = serve({