mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-30 07:21:32 -07:00
refactor(stats): drop dead IPC handlers, unify stats types over HTTP (#164)
This commit is contained in:
@@ -170,6 +170,8 @@ const TRENDS_DASHBOARD = {
|
||||
},
|
||||
ratios: {
|
||||
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
||||
cardsPerHour: [{ label: 'Mar 1', value: 12 }],
|
||||
readingSpeed: [{ label: 'Mar 1', value: 180 }],
|
||||
},
|
||||
librarySummary: [
|
||||
{
|
||||
|
||||
@@ -572,7 +572,7 @@ export class ImmersionTrackerService {
|
||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||
groupBy: 'day' | 'month' = 'day',
|
||||
fillEmptyBuckets = true,
|
||||
): Promise<unknown> {
|
||||
) {
|
||||
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
||||
}
|
||||
|
||||
|
||||
@@ -181,35 +181,6 @@ function createFakeImmersionTracker(
|
||||
): NonNullable<IpcServiceDeps['immersionTracker']> {
|
||||
return {
|
||||
recordYomitanLookup: () => {},
|
||||
getSessionSummaries: async () => [],
|
||||
getDailyRollups: async () => [],
|
||||
getMonthlyRollups: async () => [],
|
||||
getQueryHints: async () => ({
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalActiveMin: 0,
|
||||
totalCards: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async () => [],
|
||||
getSessionEvents: async () => [],
|
||||
getVocabularyStats: async () => [],
|
||||
getKanjiStats: async () => [],
|
||||
getMediaLibrary: async () => [],
|
||||
getMediaDetail: async () => null,
|
||||
getMediaSessions: async () => [],
|
||||
getMediaDailyRollups: async () => [],
|
||||
getCoverArt: async () => null,
|
||||
markActiveVideoWatched: async () => false,
|
||||
...overrides,
|
||||
};
|
||||
@@ -776,172 +747,6 @@ test('registerIpcHandlers records yomitan lookup when subtitle context recording
|
||||
}
|
||||
});
|
||||
|
||||
test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
registerIpcHandlers(createRegisterIpcDeps(), registrar);
|
||||
|
||||
const overviewHandler = handlers.handle.get(IPC_CHANNELS.request.statsGetOverview);
|
||||
assert.ok(overviewHandler);
|
||||
assert.deepEqual(await overviewHandler!({}), {
|
||||
sessions: [],
|
||||
rollups: [],
|
||||
hints: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalCards: 0,
|
||||
totalActiveMin: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('registerIpcHandlers validates and clamps stats request limits', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<[string, number, number?]> = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: {
|
||||
recordYomitanLookup: () => {},
|
||||
getSessionSummaries: async (limit = 0) => {
|
||||
calls.push(['sessions', limit]);
|
||||
return [];
|
||||
},
|
||||
getDailyRollups: async (limit = 0) => {
|
||||
calls.push(['daily', limit]);
|
||||
return [];
|
||||
},
|
||||
getMonthlyRollups: async (limit = 0) => {
|
||||
calls.push(['monthly', limit]);
|
||||
return [];
|
||||
},
|
||||
getQueryHints: async () => ({
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalCards: 0,
|
||||
totalActiveMin: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async (sessionId: number, limit = 0) => {
|
||||
calls.push(['timeline', limit, sessionId]);
|
||||
return [];
|
||||
},
|
||||
getSessionEvents: async (sessionId: number, limit = 0) => {
|
||||
calls.push(['events', limit, sessionId]);
|
||||
return [];
|
||||
},
|
||||
getVocabularyStats: async (limit = 0) => {
|
||||
calls.push(['vocabulary', limit]);
|
||||
return [];
|
||||
},
|
||||
getKanjiStats: async (limit = 0) => {
|
||||
calls.push(['kanji', limit]);
|
||||
return [];
|
||||
},
|
||||
getMediaLibrary: async () => [],
|
||||
getMediaDetail: async () => null,
|
||||
getMediaSessions: async () => [],
|
||||
getMediaDailyRollups: async () => [],
|
||||
getCoverArt: async () => null,
|
||||
markActiveVideoWatched: async () => false,
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetDailyRollups)!({}, -1);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetMonthlyRollups)!(
|
||||
{},
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessions)!({}, 9999);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, 12.5);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionEvents)!({}, 7, 0);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetVocabulary)!({}, 1000);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetKanji)!({}, NaN);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['daily', 60],
|
||||
['monthly', 24],
|
||||
['sessions', 500],
|
||||
['timeline', 200, 7],
|
||||
['events', 500, 7],
|
||||
['vocabulary', 500],
|
||||
['kanji', 100],
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers requests the full timeline when no limit is provided', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<[string, number | undefined, number]> = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: {
|
||||
recordYomitanLookup: () => {},
|
||||
getSessionSummaries: async () => [],
|
||||
getDailyRollups: async () => [],
|
||||
getMonthlyRollups: async () => [],
|
||||
getQueryHints: async () => ({
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalCards: 0,
|
||||
totalActiveMin: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async (sessionId: number, limit?: number) => {
|
||||
calls.push(['timeline', limit, sessionId]);
|
||||
return [];
|
||||
},
|
||||
getSessionEvents: async () => [],
|
||||
getVocabularyStats: async () => [],
|
||||
getKanjiStats: async () => [],
|
||||
getMediaLibrary: async () => [],
|
||||
getMediaDetail: async () => null,
|
||||
getMediaSessions: async () => [],
|
||||
getMediaDailyRollups: async () => [],
|
||||
getCoverArt: async () => null,
|
||||
markActiveVideoWatched: async () => false,
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, undefined);
|
||||
|
||||
assert.deepEqual(calls, [['timeline', undefined, 7]]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const saves: unknown[] = [];
|
||||
|
||||
@@ -132,35 +132,6 @@ export interface IpcServiceDeps {
|
||||
) => Promise<PlaylistBrowserMutationResult>;
|
||||
immersionTracker?: {
|
||||
recordYomitanLookup: () => void;
|
||||
getSessionSummaries: (limit?: number) => Promise<unknown>;
|
||||
getDailyRollups: (limit?: number) => Promise<unknown>;
|
||||
getMonthlyRollups: (limit?: number) => Promise<unknown>;
|
||||
getQueryHints: () => Promise<{
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
episodesToday: number;
|
||||
activeAnimeCount: number;
|
||||
totalActiveMin: number;
|
||||
totalCards: number;
|
||||
activeDays: number;
|
||||
totalEpisodesWatched: number;
|
||||
totalAnimeCompleted: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
}>;
|
||||
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getVocabularyStats: (limit?: number) => Promise<unknown>;
|
||||
getKanjiStats: (limit?: number) => Promise<unknown>;
|
||||
getMediaLibrary: () => Promise<unknown>;
|
||||
getMediaDetail: (videoId: number) => Promise<unknown>;
|
||||
getMediaSessions: (videoId: number, limit?: number) => Promise<unknown>;
|
||||
getMediaDailyRollups: (videoId: number, limit?: number) => Promise<unknown>;
|
||||
getCoverArt: (videoId: number) => Promise<unknown>;
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
} | null;
|
||||
}
|
||||
@@ -459,24 +430,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void {
|
||||
const parsePositiveIntLimit = (
|
||||
value: unknown,
|
||||
defaultValue: number,
|
||||
maxValue: number,
|
||||
): number => {
|
||||
if (!Number.isInteger(value) || (value as number) < 1) {
|
||||
return defaultValue;
|
||||
}
|
||||
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 = {}) => {
|
||||
@@ -904,115 +857,4 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1);
|
||||
},
|
||||
);
|
||||
|
||||
// Stats request handlers
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
|
||||
const tracker = deps.immersionTracker;
|
||||
if (!tracker) {
|
||||
return {
|
||||
sessions: [],
|
||||
rollups: [],
|
||||
hints: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalActiveMin: 0,
|
||||
totalCards: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
const [sessions, rollups, hints] = await Promise.all([
|
||||
tracker.getSessionSummaries(5),
|
||||
tracker.getDailyRollups(14),
|
||||
tracker.getQueryHints(),
|
||||
]);
|
||||
return { sessions, rollups, hints };
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetDailyRollups, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 60, 500);
|
||||
return deps.immersionTracker?.getDailyRollups(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMonthlyRollups, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 24, 120);
|
||||
return deps.immersionTracker?.getMonthlyRollups(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetSessions, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 50, 500);
|
||||
return deps.immersionTracker?.getSessionSummaries(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetSessionTimeline,
|
||||
async (_event, sessionId: unknown, limit: unknown) => {
|
||||
const parsedSessionId = parsePositiveInteger(sessionId);
|
||||
if (parsedSessionId === null) return [];
|
||||
const parsedLimit = limit === undefined ? undefined : parsePositiveIntLimit(limit, 200, 1000);
|
||||
return deps.immersionTracker?.getSessionTimeline(parsedSessionId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetSessionEvents,
|
||||
async (_event, sessionId: unknown, limit: unknown) => {
|
||||
const parsedSessionId = parsePositiveInteger(sessionId);
|
||||
if (parsedSessionId === null) return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 500, 1000);
|
||||
return deps.immersionTracker?.getSessionEvents(parsedSessionId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetVocabulary, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
|
||||
return deps.immersionTracker?.getVocabularyStats(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetKanji, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
|
||||
return deps.immersionTracker?.getKanjiStats(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMediaLibrary, async () => {
|
||||
return deps.immersionTracker?.getMediaLibrary() ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMediaDetail, async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getMediaDetail(videoId) ?? null;
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaSessions,
|
||||
async (_event, videoId: unknown, limit: unknown) => {
|
||||
if (typeof videoId !== 'number') return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
|
||||
return deps.immersionTracker?.getMediaSessions(videoId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaDailyRollups,
|
||||
async (_event, videoId: unknown, limit: unknown) => {
|
||||
if (typeof videoId !== 'number') return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 90, 500);
|
||||
return deps.immersionTracker?.getMediaDailyRollups(videoId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMediaCover, async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getCoverArt(videoId) ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
|
||||
import type { StatsCoverImage } from '../../types/stats-wire.js';
|
||||
|
||||
type StatsCoverImagePayload = {
|
||||
contentType: string;
|
||||
dataUrl: string;
|
||||
} | null;
|
||||
|
||||
type StatsCoverBatchBody = {
|
||||
animeIds?: unknown;
|
||||
videoIds?: unknown;
|
||||
};
|
||||
type StatsCoverImagePayload = StatsCoverImage | null;
|
||||
type StatsCoverBatchBody = Partial<Record<keyof StatsCoverImagesRequest, unknown>>;
|
||||
|
||||
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3;
|
||||
|
||||
@@ -130,7 +125,7 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json({ anime, media });
|
||||
return c.json(statsJson('coverImages', { anime, media }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
||||
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
||||
import { registerStatsCoverRoutes } from './stats-cover-routes.js';
|
||||
import {
|
||||
statsJson,
|
||||
type StatsAnilistSearchResult,
|
||||
type StatsAnkiBrowseResponse,
|
||||
} from '../../types/stats-http-contract.js';
|
||||
import type { StatsExcludedWord } from '../../types/stats-wire.js';
|
||||
import {
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||
resolveSecondarySubtitleTextFromSidecar,
|
||||
@@ -46,12 +52,6 @@ export type StatsMiningTimingEvent = {
|
||||
noteId?: number;
|
||||
};
|
||||
|
||||
type StatsExcludedWordPayload = {
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
};
|
||||
|
||||
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number(raw);
|
||||
@@ -87,12 +87,12 @@ function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | null {
|
||||
function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | null {
|
||||
if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const words: StatsExcludedWordPayload[] = [];
|
||||
const words: StatsExcludedWord[] = [];
|
||||
for (const row of (body as { words: unknown[] }).words) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const { headword, word, reading } = row as Record<string, unknown>;
|
||||
@@ -320,20 +320,22 @@ function summarizeFilteredWordOccurrences(
|
||||
return { knownWordsSeen, totalWordsSeen };
|
||||
}
|
||||
|
||||
async function enrichSessionsWithKnownWordMetrics(
|
||||
tracker: ImmersionTrackerService,
|
||||
sessions: Array<{
|
||||
async function enrichSessionsWithKnownWordMetrics<
|
||||
Session extends {
|
||||
sessionId: number;
|
||||
tokensSeen: number;
|
||||
}>,
|
||||
},
|
||||
>(
|
||||
tracker: ImmersionTrackerService,
|
||||
sessions: Session[],
|
||||
knownWordsCachePath?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
sessionId: number;
|
||||
tokensSeen: number;
|
||||
knownWordsSeen: number;
|
||||
knownWordRate: number;
|
||||
}>
|
||||
Array<
|
||||
Session & {
|
||||
knownWordsSeen: number;
|
||||
knownWordRate: number;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
|
||||
if (!knownWordsSet) {
|
||||
@@ -603,46 +605,48 @@ export function createStatsApp(
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ sessions, rollups, hints });
|
||||
return c.json(statsJson('overview', { sessions, rollups, hints }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/daily-rollups', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 60, 500);
|
||||
const rollups = await tracker.getDailyRollups(limit);
|
||||
return c.json(rollups);
|
||||
return c.json(statsJson('dailyRollups', rollups));
|
||||
});
|
||||
|
||||
app.get('/api/stats/monthly-rollups', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 24, 120);
|
||||
const rollups = await tracker.getMonthlyRollups(limit);
|
||||
return c.json(rollups);
|
||||
return c.json(statsJson('monthlyRollups', rollups));
|
||||
});
|
||||
|
||||
app.get('/api/stats/streak-calendar', async (c) => {
|
||||
const days = parseIntQuery(c.req.query('days'), 90, 365);
|
||||
return c.json(await tracker.getStreakCalendar(days));
|
||||
return c.json(statsJson('streakCalendar', await tracker.getStreakCalendar(days)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/trends/episodes-per-day', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||
return c.json(await tracker.getEpisodesPerDay(limit));
|
||||
return c.json(statsJson('episodesPerDay', await tracker.getEpisodesPerDay(limit)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/trends/new-anime-per-day', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||
return c.json(await tracker.getNewAnimePerDay(limit));
|
||||
return c.json(statsJson('newAnimePerDay', await tracker.getNewAnimePerDay(limit)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||
return c.json(await tracker.getWatchTimePerAnime(limit));
|
||||
return c.json(statsJson('watchTimePerAnime', await tracker.getWatchTimePerAnime(limit)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/trends/dashboard', async (c) => {
|
||||
const range = parseTrendRange(c.req.query('range'));
|
||||
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
||||
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
|
||||
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
|
||||
return c.json(
|
||||
statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)),
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions', async (c) => {
|
||||
@@ -653,30 +657,30 @@ export function createStatsApp(
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json(sessions);
|
||||
return c.json(statsJson('sessions', sessions));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions/:id/timeline', async (c) => {
|
||||
const id = parseIntQuery(c.req.param('id'), 0);
|
||||
if (id <= 0) return c.json([], 400);
|
||||
if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400);
|
||||
const rawLimit = c.req.query('limit');
|
||||
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
|
||||
const timeline = await tracker.getSessionTimeline(id, limit);
|
||||
return c.json(timeline);
|
||||
return c.json(statsJson('sessionTimeline', timeline));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions/:id/events', async (c) => {
|
||||
const id = parseIntQuery(c.req.param('id'), 0);
|
||||
if (id <= 0) return c.json([], 400);
|
||||
if (id <= 0) return c.json(statsJson('sessionEvents', []), 400);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
|
||||
const eventTypes = parseEventTypesQuery(c.req.query('types'));
|
||||
const events = await tracker.getSessionEvents(id, limit, eventTypes);
|
||||
return c.json(events);
|
||||
return c.json(statsJson('sessionEvents', events));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
|
||||
const id = parseIntQuery(c.req.param('id'), 0);
|
||||
if (id <= 0) return c.json([], 400);
|
||||
if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
|
||||
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
|
||||
|
||||
@@ -720,18 +724,18 @@ export function createStatsApp(
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(knownByLinesSeen);
|
||||
return c.json(statsJson('sessionKnownWordsTimeline', knownByLinesSeen));
|
||||
});
|
||||
|
||||
app.get('/api/stats/vocabulary', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
|
||||
const vocab = await tracker.getVocabularyStats(limit, excludePos);
|
||||
return c.json(vocab);
|
||||
return c.json(statsJson('vocabulary', vocab));
|
||||
});
|
||||
|
||||
app.get('/api/stats/excluded-words', async (c) => {
|
||||
return c.json(await tracker.getStatsExcludedWords());
|
||||
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
|
||||
});
|
||||
|
||||
app.put('/api/stats/excluded-words', async (c) => {
|
||||
@@ -739,7 +743,7 @@ export function createStatsApp(
|
||||
const words = parseExcludedWordsBody(body);
|
||||
if (!words) return c.body(null, 400);
|
||||
await tracker.replaceStatsExcludedWords(words);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('setExcludedWords', { ok: true }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/vocabulary/occurrences', async (c) => {
|
||||
@@ -747,17 +751,17 @@ export function createStatsApp(
|
||||
const word = (c.req.query('word') ?? '').trim();
|
||||
const reading = (c.req.query('reading') ?? '').trim();
|
||||
if (!headword || !word) {
|
||||
return c.json([], 400);
|
||||
return c.json(statsJson('wordOccurrences', []), 400);
|
||||
}
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
||||
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset);
|
||||
return c.json(occurrences);
|
||||
return c.json(statsJson('wordOccurrences', occurrences));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sentences/search', async (c) => {
|
||||
const query = (c.req.query('q') ?? '').trim();
|
||||
if (!query) return c.json([]);
|
||||
if (!query) return c.json(statsJson('sentenceSearch', []));
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
|
||||
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
|
||||
const searchOptions = await buildSentenceSearchOptions(
|
||||
@@ -766,24 +770,24 @@ export function createStatsApp(
|
||||
options?.resolveSentenceSearchHeadwords,
|
||||
);
|
||||
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
|
||||
return c.json(rows);
|
||||
return c.json(statsJson('sentenceSearch', rows));
|
||||
});
|
||||
|
||||
app.get('/api/stats/kanji', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||
const kanji = await tracker.getKanjiStats(limit);
|
||||
return c.json(kanji);
|
||||
return c.json(statsJson('kanji', kanji));
|
||||
});
|
||||
|
||||
app.get('/api/stats/kanji/occurrences', async (c) => {
|
||||
const kanji = (c.req.query('kanji') ?? '').trim();
|
||||
if (!kanji) {
|
||||
return c.json([], 400);
|
||||
return c.json(statsJson('kanjiOccurrences', []), 400);
|
||||
}
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
||||
const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset);
|
||||
return c.json(occurrences);
|
||||
return c.json(statsJson('kanjiOccurrences', occurrences));
|
||||
});
|
||||
|
||||
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
|
||||
@@ -793,7 +797,7 @@ export function createStatsApp(
|
||||
if (!detail) return c.body(null, 404);
|
||||
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
|
||||
const similarWords = await tracker.getSimilarWords(wordId);
|
||||
return c.json({ detail, animeAppearances, similarWords });
|
||||
return c.json(statsJson('wordDetail', { detail, animeAppearances, similarWords }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
|
||||
@@ -803,17 +807,17 @@ export function createStatsApp(
|
||||
if (!detail) return c.body(null, 404);
|
||||
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
|
||||
const words = await tracker.getKanjiWords(kanjiId);
|
||||
return c.json({ detail, animeAppearances, words });
|
||||
return c.json(statsJson('kanjiDetail', { detail, animeAppearances, words }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/media', async (c) => {
|
||||
const library = await tracker.getMediaLibrary();
|
||||
return c.json(library);
|
||||
return c.json(statsJson('mediaLibrary', library));
|
||||
});
|
||||
|
||||
app.get('/api/stats/media/:videoId', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.json(null, 400);
|
||||
if (videoId <= 0) return c.json(statsJson('error', null), 400);
|
||||
const [detail, rawSessions, rollups] = await Promise.all([
|
||||
tracker.getMediaDetail(videoId),
|
||||
tracker.getMediaSessions(videoId, 100),
|
||||
@@ -824,12 +828,12 @@ export function createStatsApp(
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ detail, sessions, rollups });
|
||||
return c.json(statsJson('mediaDetail', { detail, sessions, rollups }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime', async (c) => {
|
||||
const rows = await tracker.getAnimeLibrary();
|
||||
return c.json(rows);
|
||||
return c.json(statsJson('animeLibrary', rows));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId', async (c) => {
|
||||
@@ -841,21 +845,21 @@ export function createStatsApp(
|
||||
tracker.getAnimeEpisodes(animeId),
|
||||
tracker.getAnimeAnilistEntries(animeId),
|
||||
]);
|
||||
return c.json({ detail, episodes, anilistEntries });
|
||||
return c.json(statsJson('animeDetail', { detail, episodes, anilistEntries }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/words', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
|
||||
if (animeId <= 0) return c.body(null, 400);
|
||||
return c.json(await tracker.getAnimeWords(animeId, limit));
|
||||
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||
if (animeId <= 0) return c.body(null, 400);
|
||||
return c.json(await tracker.getAnimeDailyRollups(animeId, limit));
|
||||
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
|
||||
});
|
||||
|
||||
app.patch('/api/stats/media/:videoId/watched', async (c) => {
|
||||
@@ -864,7 +868,7 @@ export function createStatsApp(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
|
||||
await tracker.setVideoWatched(videoId, watched);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('setVideoWatched', { ok: true }));
|
||||
});
|
||||
|
||||
app.delete('/api/stats/sessions', async (c) => {
|
||||
@@ -874,26 +878,26 @@ export function createStatsApp(
|
||||
: [];
|
||||
if (ids.length === 0) return c.body(null, 400);
|
||||
await tracker.deleteSessions(ids);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('deleteSessions', { ok: true }));
|
||||
});
|
||||
|
||||
app.delete('/api/stats/sessions/:sessionId', async (c) => {
|
||||
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
|
||||
if (sessionId <= 0) return c.body(null, 400);
|
||||
await tracker.deleteSession(sessionId);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('deleteSession', { ok: true }));
|
||||
});
|
||||
|
||||
app.delete('/api/stats/media/:videoId', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.body(null, 400);
|
||||
await tracker.deleteVideo(videoId);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('deleteVideo', { ok: true }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anilist/search', async (c) => {
|
||||
const query = (c.req.query('q') ?? '').trim();
|
||||
if (!query) return c.json([]);
|
||||
if (!query) return c.json(statsJson('anilistSearch', []));
|
||||
try {
|
||||
await options?.anilistRateLimiter?.acquire();
|
||||
const res = await fetch('https://graphql.anilist.co', {
|
||||
@@ -918,44 +922,66 @@ export function createStatsApp(
|
||||
});
|
||||
options?.anilistRateLimiter?.recordResponse(res.headers);
|
||||
if (res.status === 429) {
|
||||
return c.json([]);
|
||||
return c.json(statsJson('anilistSearch', []));
|
||||
}
|
||||
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
|
||||
return c.json(json.data?.Page?.media ?? []);
|
||||
const json = (await res.json()) as {
|
||||
data?: { Page?: { media?: StatsAnilistSearchResult[] } };
|
||||
};
|
||||
return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? []));
|
||||
} catch {
|
||||
return c.json([]);
|
||||
return c.json(statsJson('anilistSearch', []));
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stats/known-words', (c) => {
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
if (!knownWordsSet) return c.json([]);
|
||||
return c.json([...knownWordsSet]);
|
||||
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
|
||||
return c.json(statsJson('knownWords', [...knownWordsSet]));
|
||||
});
|
||||
|
||||
app.get('/api/stats/known-words-summary', async (c) => {
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
||||
if (!knownWordsSet) {
|
||||
return c.json(statsJson('knownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }));
|
||||
}
|
||||
const headwords = await tracker.getAllDistinctHeadwords();
|
||||
return c.json(countKnownWords(headwords, knownWordsSet));
|
||||
return c.json(statsJson('knownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
|
||||
if (animeId <= 0) {
|
||||
return c.json(
|
||||
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||
400,
|
||||
);
|
||||
}
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
||||
if (!knownWordsSet) {
|
||||
return c.json(
|
||||
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||
);
|
||||
}
|
||||
const headwords = await tracker.getAnimeDistinctHeadwords(animeId);
|
||||
return c.json(countKnownWords(headwords, knownWordsSet));
|
||||
return c.json(statsJson('animeKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
|
||||
if (videoId <= 0) {
|
||||
return c.json(
|
||||
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||
400,
|
||||
);
|
||||
}
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
||||
if (!knownWordsSet) {
|
||||
return c.json(
|
||||
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||
);
|
||||
}
|
||||
const headwords = await tracker.getMediaDistinctHeadwords(videoId);
|
||||
return c.json(countKnownWords(headwords, knownWordsSet));
|
||||
return c.json(statsJson('mediaKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||
});
|
||||
|
||||
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
|
||||
@@ -964,7 +990,7 @@ export function createStatsApp(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body?.anilistId) return c.body(null, 400);
|
||||
await tracker.reassignAnimeAnilist(animeId, body);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
|
||||
});
|
||||
|
||||
registerStatsCoverRoutes(app, tracker);
|
||||
@@ -980,7 +1006,7 @@ export function createStatsApp(
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ sessions, words, cardEvents });
|
||||
return c.json(statsJson('episodeDetail', { sessions, words, cardEvents }));
|
||||
});
|
||||
|
||||
app.post('/api/stats/anki/browse', async (c) => {
|
||||
@@ -998,10 +1024,10 @@ export function createStatsApp(
|
||||
params: { query: `nid:${noteId}` },
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
return c.json(result);
|
||||
const result = (await response.json()) as StatsAnkiBrowseResponse;
|
||||
return c.json(statsJson('ankiBrowse', result));
|
||||
} catch {
|
||||
return c.json({ error: 'Failed to reach AnkiConnect' }, 502);
|
||||
return c.json(statsJson('error', { error: 'Failed to reach AnkiConnect' }), 502);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1012,7 +1038,7 @@ export function createStatsApp(
|
||||
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
||||
)
|
||||
: [];
|
||||
if (noteIds.length === 0) return c.json([]);
|
||||
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
|
||||
const resolvedNoteIds = Array.from(
|
||||
new Set(
|
||||
noteIds.map((noteId) => {
|
||||
@@ -1039,13 +1065,16 @@ export function createStatsApp(
|
||||
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||
};
|
||||
return c.json(
|
||||
(result.result ?? []).map((note) => ({
|
||||
...note,
|
||||
preview: buildAnkiNotePreview(note.fields, ankiConfig),
|
||||
})),
|
||||
statsJson(
|
||||
'ankiNotesInfo',
|
||||
(result.result ?? []).map((note) => ({
|
||||
...note,
|
||||
preview: buildAnkiNotePreview(note.fields, ankiConfig),
|
||||
})),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return c.json([], 502);
|
||||
return c.json(statsJson('ankiNotesInfo', []), 502);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1063,19 +1092,24 @@ export function createStatsApp(
|
||||
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
||||
|
||||
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
||||
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
|
||||
return c.json(
|
||||
statsJson('mineCard', {
|
||||
error: 'sourcePath, sentence, startMs, and endMs are required',
|
||||
}),
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (endMs <= startMs) {
|
||||
return c.json({ error: 'endMs must be greater than startMs' }, 400);
|
||||
return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400);
|
||||
}
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
return c.json(statsJson('mineCard', { error: 'File not found' }), 404);
|
||||
}
|
||||
|
||||
const ankiConfig = getAnkiConnectConfig();
|
||||
if (!ankiConfig) {
|
||||
return c.json({ error: 'AnkiConnect is not configured' }, 500);
|
||||
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
|
||||
}
|
||||
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
|
||||
let retimedSecondaryText = '';
|
||||
@@ -1203,7 +1237,7 @@ export function createStatsApp(
|
||||
|
||||
if (mode === 'word') {
|
||||
if (!options?.addYomitanNote) {
|
||||
return c.json({ error: 'Yomitan bridge not available' }, 500);
|
||||
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500);
|
||||
}
|
||||
|
||||
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
||||
@@ -1219,9 +1253,9 @@ export function createStatsApp(
|
||||
|
||||
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
|
||||
return c.json(
|
||||
{
|
||||
statsJson('mineCard', {
|
||||
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
|
||||
},
|
||||
}),
|
||||
502,
|
||||
);
|
||||
}
|
||||
@@ -1336,7 +1370,7 @@ export function createStatsApp(
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
||||
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||
}
|
||||
|
||||
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
||||
@@ -1394,7 +1428,9 @@ export function createStatsApp(
|
||||
|
||||
if (addNoteResult.status === 'rejected') {
|
||||
return c.json(
|
||||
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
|
||||
statsJson('mineCard', {
|
||||
error: `Failed to add note: ${(addNoteResult.reason as Error).message}`,
|
||||
}),
|
||||
502,
|
||||
);
|
||||
}
|
||||
@@ -1470,7 +1506,7 @@ export function createStatsApp(
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
||||
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||
});
|
||||
|
||||
if (options?.staticDir) {
|
||||
|
||||
Reference in New Issue
Block a user