mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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:
@@ -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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user