mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
refactor(stats): drop dead IPC handlers, unify stats types over HTTP (#164)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AnimeVisibilityFilter } from './TrendsTab';
|
||||
@@ -81,7 +82,7 @@ test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing
|
||||
});
|
||||
|
||||
test('TrendsTab source labels words per minute without reading speed wording', async () => {
|
||||
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
|
||||
const source = await readFile(new URL('./TrendsTab.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /title="Words \/ Min"/);
|
||||
assert.doesNotMatch(source, /Reading Speed/);
|
||||
|
||||
+62
-105
@@ -1,30 +1,17 @@
|
||||
import type {
|
||||
OverviewData,
|
||||
DailyRollup,
|
||||
MonthlyRollup,
|
||||
SessionSummary,
|
||||
SessionTimelinePoint,
|
||||
SessionEvent,
|
||||
VocabularyEntry,
|
||||
SentenceSearchResult,
|
||||
KanjiEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
MediaLibraryItem,
|
||||
MediaDetailData,
|
||||
AnimeLibraryItem,
|
||||
AnimeDetailData,
|
||||
AnimeWord,
|
||||
StreakCalendarDay,
|
||||
EpisodesPerDay,
|
||||
NewAnimePerDay,
|
||||
WatchTimePerAnime,
|
||||
TrendsDashboardData,
|
||||
WordDetailData,
|
||||
KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
StatsAnkiNoteInfo,
|
||||
StatsExcludedWord,
|
||||
StatsCoverImagesData,
|
||||
StatsAnilistAssignment,
|
||||
StatsAnkiNotesInfoRequest,
|
||||
StatsCoverImagesRequest,
|
||||
StatsDeleteSessionsRequest,
|
||||
StatsExcludedWordsRequest,
|
||||
StatsHttpClient,
|
||||
StatsJsonResponseMap,
|
||||
StatsTrendGroupBy,
|
||||
StatsTrendRange,
|
||||
StatsVideoWatchedRequest,
|
||||
} from '../types/stats';
|
||||
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
|
||||
import { appendCoverRetryToken } from './cover-retry';
|
||||
@@ -64,9 +51,12 @@ async function fetchResponse(path: string, init?: RequestInit): Promise<Response
|
||||
return res;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
async function fetchJson<Key extends keyof StatsJsonResponseMap>(
|
||||
_key: Key,
|
||||
path: string,
|
||||
): Promise<StatsJsonResponseMap[Key]> {
|
||||
const res = await fetchResponse(path);
|
||||
return res.json() as Promise<T>;
|
||||
return res.json() as Promise<StatsJsonResponseMap[Key]>;
|
||||
}
|
||||
|
||||
function uniquePositiveIds(ids: number[]): number[] {
|
||||
@@ -80,14 +70,15 @@ function uniquePositiveIds(ids: number[]): number[] {
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
|
||||
getOverview: () => fetchJson('overview', '/api/stats/overview'),
|
||||
getDailyRollups: (limit = 60) =>
|
||||
fetchJson<DailyRollup[]>(`/api/stats/daily-rollups?limit=${limit}`),
|
||||
fetchJson('dailyRollups', `/api/stats/daily-rollups?limit=${limit}`),
|
||||
getMonthlyRollups: (limit = 24) =>
|
||||
fetchJson<MonthlyRollup[]>(`/api/stats/monthly-rollups?limit=${limit}`),
|
||||
getSessions: (limit = 50) => fetchJson<SessionSummary[]>(`/api/stats/sessions?limit=${limit}`),
|
||||
fetchJson('monthlyRollups', `/api/stats/monthly-rollups?limit=${limit}`),
|
||||
getSessions: (limit = 50) => fetchJson('sessions', `/api/stats/sessions?limit=${limit}`),
|
||||
getSessionTimeline: (id: number, limit?: number) =>
|
||||
fetchJson<SessionTimelinePoint[]>(
|
||||
fetchJson(
|
||||
'sessionTimeline',
|
||||
limit === undefined
|
||||
? `/api/stats/sessions/${id}/timeline`
|
||||
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
|
||||
@@ -97,90 +88,84 @@ export const apiClient = {
|
||||
if (eventTypes && eventTypes.length > 0) {
|
||||
params.set('types', eventTypes.join(','));
|
||||
}
|
||||
return fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?${params.toString()}`);
|
||||
return fetchJson('sessionEvents', `/api/stats/sessions/${id}/events?${params.toString()}`);
|
||||
},
|
||||
getSessionKnownWordsTimeline: (id: number) =>
|
||||
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number; totalWordsSeen: number }>>(
|
||||
`/api/stats/sessions/${id}/known-words-timeline`,
|
||||
),
|
||||
getVocabulary: (limit = 100) =>
|
||||
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
|
||||
getExcludedWords: () => fetchJson<StatsExcludedWord[]>('/api/stats/excluded-words'),
|
||||
fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`),
|
||||
getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`),
|
||||
getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
|
||||
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
|
||||
await fetchResponse('/api/stats/excluded-words', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ words }),
|
||||
body: JSON.stringify({ words } satisfies StatsExcludedWordsRequest),
|
||||
});
|
||||
},
|
||||
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
|
||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||
fetchJson(
|
||||
'wordOccurrences',
|
||||
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
|
||||
fetchJson<SentenceSearchResult[]>(
|
||||
fetchJson(
|
||||
'sentenceSearch',
|
||||
`/api/stats/sentences/search?${new URLSearchParams({
|
||||
q: query,
|
||||
limit: String(limit),
|
||||
headword: String(searchByHeadword),
|
||||
}).toString()}`,
|
||||
),
|
||||
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
|
||||
getKanji: (limit = 100) => fetchJson('kanji', `/api/stats/kanji?limit=${limit}`),
|
||||
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||
fetchJson(
|
||||
'kanjiOccurrences',
|
||||
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'),
|
||||
getMediaDetail: (videoId: number) => fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
|
||||
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
|
||||
getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
|
||||
getMediaLibrary: () => fetchJson('mediaLibrary', '/api/stats/media'),
|
||||
getMediaDetail: (videoId: number) => fetchJson('mediaDetail', `/api/stats/media/${videoId}`),
|
||||
getAnimeLibrary: () => fetchJson('animeLibrary', '/api/stats/anime'),
|
||||
getAnimeDetail: (animeId: number) => fetchJson('animeDetail', `/api/stats/anime/${animeId}`),
|
||||
getAnimeWords: (animeId: number, limit = 50) =>
|
||||
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||
fetchJson('animeWords', `/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||
getAnimeRollups: (animeId: number, limit = 90) =>
|
||||
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
||||
fetchJson('animeRollups', `/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
||||
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
|
||||
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
|
||||
getCoverImages: async (params: {
|
||||
animeIds: number[];
|
||||
videoIds: number[];
|
||||
}): Promise<StatsCoverImagesData> => {
|
||||
getCoverImages: async (params: StatsCoverImagesRequest): Promise<StatsCoverImagesData> => {
|
||||
const res = await fetchResponse('/api/stats/covers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
animeIds: uniquePositiveIds(params.animeIds),
|
||||
videoIds: uniquePositiveIds(params.videoIds),
|
||||
}),
|
||||
} satisfies StatsCoverImagesRequest),
|
||||
});
|
||||
return res.json() as Promise<StatsCoverImagesData>;
|
||||
return res.json() as Promise<StatsJsonResponseMap['coverImages']>;
|
||||
},
|
||||
getStreakCalendar: (days = 90) =>
|
||||
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
|
||||
fetchJson('streakCalendar', `/api/stats/streak-calendar?days=${days}`),
|
||||
getEpisodesPerDay: (limit = 90) =>
|
||||
fetchJson<EpisodesPerDay[]>(`/api/stats/trends/episodes-per-day?limit=${limit}`),
|
||||
fetchJson('episodesPerDay', `/api/stats/trends/episodes-per-day?limit=${limit}`),
|
||||
getNewAnimePerDay: (limit = 90) =>
|
||||
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
||||
fetchJson('newAnimePerDay', `/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
||||
getWatchTimePerAnime: (limit = 90) =>
|
||||
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
||||
getTrendsDashboard: (
|
||||
range: '7d' | '30d' | '90d' | '365d' | 'all',
|
||||
groupBy: 'day' | 'month',
|
||||
fillEmpty = true,
|
||||
) =>
|
||||
fetchJson<TrendsDashboardData>(
|
||||
fetchJson('watchTimePerAnime', `/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
||||
getTrendsDashboard: (range: StatsTrendRange, groupBy: StatsTrendGroupBy, fillEmpty = true) =>
|
||||
fetchJson(
|
||||
'trendsDashboard',
|
||||
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`,
|
||||
),
|
||||
getWordDetail: (wordId: number) =>
|
||||
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
|
||||
fetchJson('wordDetail', `/api/stats/vocabulary/${wordId}/detail`),
|
||||
getKanjiDetail: (kanjiId: number) =>
|
||||
fetchJson<KanjiDetailData>(`/api/stats/kanji/${kanjiId}/detail`),
|
||||
fetchJson('kanjiDetail', `/api/stats/kanji/${kanjiId}/detail`),
|
||||
getEpisodeDetail: (videoId: number) =>
|
||||
fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
|
||||
fetchJson('episodeDetail', `/api/stats/episode/${videoId}/detail`),
|
||||
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/media/${videoId}/watched`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ watched }),
|
||||
body: JSON.stringify({ watched } satisfies StatsVideoWatchedRequest),
|
||||
});
|
||||
},
|
||||
deleteSession: async (sessionId: number): Promise<void> => {
|
||||
@@ -190,49 +175,21 @@ export const apiClient = {
|
||||
await fetchResponse('/api/stats/sessions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionIds }),
|
||||
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
|
||||
});
|
||||
},
|
||||
deleteVideo: async (videoId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
|
||||
},
|
||||
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
|
||||
getKnownWordsSummary: () =>
|
||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
||||
'/api/stats/known-words-summary',
|
||||
),
|
||||
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
|
||||
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
|
||||
getAnimeKnownWordsSummary: (animeId: number) =>
|
||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
||||
`/api/stats/anime/${animeId}/known-words-summary`,
|
||||
),
|
||||
fetchJson('animeKnownWordsSummary', `/api/stats/anime/${animeId}/known-words-summary`),
|
||||
getMediaKnownWordsSummary: (videoId: number) =>
|
||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
||||
`/api/stats/media/${videoId}/known-words-summary`,
|
||||
),
|
||||
fetchJson('mediaKnownWordsSummary', `/api/stats/media/${videoId}/known-words-summary`),
|
||||
searchAnilist: (query: string) =>
|
||||
fetchJson<
|
||||
Array<{
|
||||
id: number;
|
||||
episodes: number | null;
|
||||
season: string | null;
|
||||
seasonYear: number | null;
|
||||
description: string | null;
|
||||
coverImage: { large: string | null; medium: string | null } | null;
|
||||
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||
}>
|
||||
>(`/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
|
||||
reassignAnimeAnilist: async (
|
||||
animeId: number,
|
||||
info: {
|
||||
anilistId: number;
|
||||
titleRomaji?: string | null;
|
||||
titleEnglish?: string | null;
|
||||
titleNative?: string | null;
|
||||
episodesTotal?: number | null;
|
||||
description?: string | null;
|
||||
coverUrl?: string | null;
|
||||
},
|
||||
): Promise<void> => {
|
||||
fetchJson('anilistSearch', `/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
|
||||
reassignAnimeAnilist: async (animeId: number, info: StatsAnilistAssignment): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -254,9 +211,9 @@ export const apiClient = {
|
||||
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noteIds }),
|
||||
body: JSON.stringify({ noteIds } satisfies StatsAnkiNotesInfoRequest),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Stats API error: ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
} satisfies StatsHttpClient;
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import type {
|
||||
OverviewData,
|
||||
DailyRollup,
|
||||
MonthlyRollup,
|
||||
SessionSummary,
|
||||
SessionTimelinePoint,
|
||||
SessionEvent,
|
||||
VocabularyEntry,
|
||||
KanjiEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
MediaLibraryItem,
|
||||
MediaDetailData,
|
||||
AnimeLibraryItem,
|
||||
AnimeDetailData,
|
||||
AnimeWord,
|
||||
StreakCalendarDay,
|
||||
EpisodesPerDay,
|
||||
NewAnimePerDay,
|
||||
WatchTimePerAnime,
|
||||
WordDetailData,
|
||||
KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
StatsAnkiNoteInfo,
|
||||
} from '../types/stats';
|
||||
|
||||
interface StatsElectronAPI {
|
||||
stats: {
|
||||
getOverview: () => Promise<OverviewData>;
|
||||
getDailyRollups: (limit?: number) => Promise<DailyRollup[]>;
|
||||
getMonthlyRollups: (limit?: number) => Promise<MonthlyRollup[]>;
|
||||
getSessions: (limit?: number) => Promise<SessionSummary[]>;
|
||||
getSessionTimeline: (id: number, limit?: number) => Promise<SessionTimelinePoint[]>;
|
||||
getSessionEvents: (id: number, limit?: number) => Promise<SessionEvent[]>;
|
||||
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
|
||||
getWordOccurrences: (
|
||||
headword: string,
|
||||
word: string,
|
||||
reading: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) => Promise<VocabularyOccurrenceEntry[]>;
|
||||
getKanji: (limit?: number) => Promise<KanjiEntry[]>;
|
||||
getKanjiOccurrences: (
|
||||
kanji: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) => Promise<VocabularyOccurrenceEntry[]>;
|
||||
getMediaLibrary: () => Promise<MediaLibraryItem[]>;
|
||||
getMediaDetail: (videoId: number) => Promise<MediaDetailData>;
|
||||
getAnimeLibrary: () => Promise<AnimeLibraryItem[]>;
|
||||
getAnimeDetail: (animeId: number) => Promise<AnimeDetailData>;
|
||||
getAnimeWords: (animeId: number, limit?: number) => Promise<AnimeWord[]>;
|
||||
getAnimeRollups: (animeId: number, limit?: number) => Promise<DailyRollup[]>;
|
||||
getAnimeCoverUrl: (animeId: number) => string;
|
||||
getStreakCalendar: (days?: number) => Promise<StreakCalendarDay[]>;
|
||||
getEpisodesPerDay: (limit?: number) => Promise<EpisodesPerDay[]>;
|
||||
getNewAnimePerDay: (limit?: number) => Promise<NewAnimePerDay[]>;
|
||||
getWatchTimePerAnime: (limit?: number) => Promise<WatchTimePerAnime[]>;
|
||||
getWordDetail: (wordId: number) => Promise<WordDetailData>;
|
||||
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
|
||||
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
|
||||
ankiBrowse: (noteId: number) => Promise<void>;
|
||||
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
||||
hideOverlay: () => void;
|
||||
confirmNativeDialog?: (message: string) => boolean;
|
||||
beginNativeDialog?: () => void;
|
||||
endNativeDialog?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: StatsElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
function getIpc(): StatsElectronAPI['stats'] {
|
||||
const api = window.electronAPI?.stats;
|
||||
if (!api) throw new Error('Electron IPC not available');
|
||||
return api;
|
||||
}
|
||||
|
||||
export const ipcClient = {
|
||||
getOverview: () => getIpc().getOverview(),
|
||||
getDailyRollups: (limit = 60) => getIpc().getDailyRollups(limit),
|
||||
getMonthlyRollups: (limit = 24) => getIpc().getMonthlyRollups(limit),
|
||||
getSessions: (limit = 50) => getIpc().getSessions(limit),
|
||||
getSessionTimeline: (id: number, limit?: number) => getIpc().getSessionTimeline(id, limit),
|
||||
getSessionEvents: (id: number, limit = 500) => getIpc().getSessionEvents(id, limit),
|
||||
getVocabulary: (limit = 100) => getIpc().getVocabulary(limit),
|
||||
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
|
||||
getIpc().getWordOccurrences(headword, word, reading, limit, offset),
|
||||
getKanji: (limit = 100) => getIpc().getKanji(limit),
|
||||
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
||||
getIpc().getKanjiOccurrences(kanji, limit, offset),
|
||||
getMediaLibrary: () => getIpc().getMediaLibrary(),
|
||||
getMediaDetail: (videoId: number) => getIpc().getMediaDetail(videoId),
|
||||
getAnimeLibrary: () => getIpc().getAnimeLibrary(),
|
||||
getAnimeDetail: (animeId: number) => getIpc().getAnimeDetail(animeId),
|
||||
getAnimeWords: (animeId: number, limit = 50) => getIpc().getAnimeWords(animeId, limit),
|
||||
getAnimeRollups: (animeId: number, limit = 90) => getIpc().getAnimeRollups(animeId, limit),
|
||||
getAnimeCoverUrl: (animeId: number) => getIpc().getAnimeCoverUrl(animeId),
|
||||
getStreakCalendar: (days = 90) => getIpc().getStreakCalendar(days),
|
||||
getEpisodesPerDay: (limit = 90) => getIpc().getEpisodesPerDay(limit),
|
||||
getNewAnimePerDay: (limit = 90) => getIpc().getNewAnimePerDay(limit),
|
||||
getWatchTimePerAnime: (limit = 90) => getIpc().getWatchTimePerAnime(limit),
|
||||
getWordDetail: (wordId: number) => getIpc().getWordDetail(wordId),
|
||||
getKanjiDetail: (kanjiId: number) => getIpc().getKanjiDetail(kanjiId),
|
||||
getEpisodeDetail: (videoId: number) => getIpc().getEpisodeDetail(videoId),
|
||||
ankiBrowse: (noteId: number) => getIpc().ankiBrowse(noteId),
|
||||
ankiNotesInfo: (noteIds: number[]) => getIpc().ankiNotesInfo(noteIds),
|
||||
};
|
||||
+7
-19
@@ -1,23 +1,11 @@
|
||||
import type { SentenceSearchResult } from '../types/stats';
|
||||
import type {
|
||||
SentenceSearchResult,
|
||||
StatsMineCardParams,
|
||||
StatsMineCardResponse,
|
||||
StatsMineMode,
|
||||
} from '../types/stats';
|
||||
|
||||
export type StatsMineMode = 'word' | 'sentence' | 'audio';
|
||||
|
||||
export interface StatsMineCardParams {
|
||||
sourcePath: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
sentence: string;
|
||||
word: string;
|
||||
secondaryText?: string | null;
|
||||
videoTitle: string;
|
||||
mode: StatsMineMode;
|
||||
}
|
||||
|
||||
export interface StatsMineCardResponse {
|
||||
noteId?: number;
|
||||
error?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
export type { StatsMineCardParams, StatsMineCardResponse, StatsMineMode } from '../types/stats';
|
||||
|
||||
export function getStatsMineCardUnavailableReason(
|
||||
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
|
||||
|
||||
+2
-430
@@ -1,430 +1,2 @@
|
||||
export interface SessionSummary {
|
||||
sessionId: number;
|
||||
canonicalTitle: string | null;
|
||||
videoId: number | null;
|
||||
animeId: number | null;
|
||||
animeTitle: string | null;
|
||||
startedAtMs: number;
|
||||
endedAtMs: number | null;
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
linesSeen: number;
|
||||
tokensSeen: number;
|
||||
cardsMined: number;
|
||||
lookupCount: number;
|
||||
lookupHits: number;
|
||||
yomitanLookupCount: number;
|
||||
knownWordsSeen: number;
|
||||
knownWordRate: number;
|
||||
}
|
||||
|
||||
export interface DailyRollup {
|
||||
rollupDayOrMonth: number;
|
||||
videoId: number | null;
|
||||
totalSessions: number;
|
||||
totalActiveMin: number;
|
||||
totalLinesSeen: number;
|
||||
totalTokensSeen: number;
|
||||
totalCards: number;
|
||||
cardsPerHour: number | null;
|
||||
tokensPerMin: number | null;
|
||||
lookupHitRate: number | null;
|
||||
}
|
||||
|
||||
export type MonthlyRollup = DailyRollup;
|
||||
|
||||
export interface SessionTimelinePoint {
|
||||
sampleMs: number;
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
linesSeen: number;
|
||||
tokensSeen: number;
|
||||
cardsMined: number;
|
||||
}
|
||||
|
||||
export interface SessionEvent {
|
||||
eventType: EventType;
|
||||
tsMs: number;
|
||||
payload: string | null;
|
||||
}
|
||||
|
||||
export interface AnkiNotePreview {
|
||||
word: string;
|
||||
sentence: string;
|
||||
translation: string;
|
||||
}
|
||||
|
||||
export interface StatsAnkiNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
preview?: AnkiNotePreview;
|
||||
}
|
||||
|
||||
export interface VocabularyEntry {
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
partOfSpeech: string | null;
|
||||
pos1: string | null;
|
||||
pos2: string | null;
|
||||
pos3: string | null;
|
||||
frequency: number;
|
||||
frequencyRank: number | null;
|
||||
animeCount: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export interface StatsExcludedWord {
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
}
|
||||
|
||||
export interface StatsCoverImage {
|
||||
contentType: string;
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
export interface StatsCoverImagesData {
|
||||
anime: Record<number, StatsCoverImage | null>;
|
||||
media: Record<number, StatsCoverImage | null>;
|
||||
}
|
||||
|
||||
export interface KanjiEntry {
|
||||
kanjiId: number;
|
||||
kanji: string;
|
||||
frequency: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export interface VocabularyOccurrenceEntry {
|
||||
animeId: number | null;
|
||||
animeTitle: string | null;
|
||||
videoId: number;
|
||||
videoTitle: string;
|
||||
sourcePath: string | null;
|
||||
secondaryText: string | null;
|
||||
sessionId: number;
|
||||
lineIndex: number;
|
||||
segmentStartMs: number | null;
|
||||
segmentEndMs: number | null;
|
||||
text: string;
|
||||
occurrenceCount: number;
|
||||
}
|
||||
|
||||
export interface SentenceSearchResult {
|
||||
animeId: number | null;
|
||||
animeTitle: string | null;
|
||||
videoId: number;
|
||||
videoTitle: string;
|
||||
sourcePath: string | null;
|
||||
secondaryText: string | null;
|
||||
sessionId: number;
|
||||
lineIndex: number;
|
||||
segmentStartMs: number | null;
|
||||
segmentEndMs: number | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface OverviewData {
|
||||
sessions: SessionSummary[];
|
||||
rollups: DailyRollup[];
|
||||
hints: {
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
episodesToday: number;
|
||||
activeAnimeCount: number;
|
||||
totalEpisodesWatched: number;
|
||||
totalAnimeCompleted: number;
|
||||
totalActiveMin: number;
|
||||
activeDays: number;
|
||||
totalCards?: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MediaLibraryItem {
|
||||
videoId: number;
|
||||
canonicalTitle: string;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalTokensSeen: number;
|
||||
lastWatchedMs: number;
|
||||
hasCoverArt: number;
|
||||
youtubeVideoId?: string | null;
|
||||
videoUrl?: string | null;
|
||||
videoTitle?: string | null;
|
||||
videoThumbnailUrl?: string | null;
|
||||
channelId?: string | null;
|
||||
channelName?: string | null;
|
||||
channelUrl?: string | null;
|
||||
channelThumbnailUrl?: string | null;
|
||||
uploaderId?: string | null;
|
||||
uploaderUrl?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface MediaDetailData {
|
||||
detail: {
|
||||
videoId: number;
|
||||
canonicalTitle: string;
|
||||
animeId: number | null;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalTokensSeen: number;
|
||||
totalLinesSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
youtubeVideoId?: string | null;
|
||||
videoUrl?: string | null;
|
||||
videoTitle?: string | null;
|
||||
videoThumbnailUrl?: string | null;
|
||||
channelId?: string | null;
|
||||
channelName?: string | null;
|
||||
channelUrl?: string | null;
|
||||
channelThumbnailUrl?: string | null;
|
||||
uploaderId?: string | null;
|
||||
uploaderUrl?: string | null;
|
||||
description?: string | null;
|
||||
} | null;
|
||||
sessions: SessionSummary[];
|
||||
rollups: DailyRollup[];
|
||||
}
|
||||
|
||||
export const EventType = {
|
||||
SUBTITLE_LINE: 1,
|
||||
MEDIA_BUFFER: 2,
|
||||
LOOKUP: 3,
|
||||
CARD_MINED: 4,
|
||||
SEEK_FORWARD: 5,
|
||||
SEEK_BACKWARD: 6,
|
||||
PAUSE_START: 7,
|
||||
PAUSE_END: 8,
|
||||
YOMITAN_LOOKUP: 9,
|
||||
} as const;
|
||||
|
||||
export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||
|
||||
export interface AnimeLibraryItem {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalTokensSeen: number;
|
||||
episodeCount: number;
|
||||
episodesTotal: number | null;
|
||||
lastWatchedMs: number;
|
||||
}
|
||||
|
||||
export interface AnilistEntry {
|
||||
anilistId: number;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
season: number | null;
|
||||
}
|
||||
|
||||
export interface AnimeDetailData {
|
||||
detail: {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
description: string | null;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalTokensSeen: number;
|
||||
totalLinesSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
episodeCount: number;
|
||||
lastWatchedMs: number;
|
||||
};
|
||||
episodes: AnimeEpisode[];
|
||||
anilistEntries: AnilistEntry[];
|
||||
}
|
||||
|
||||
export interface AnimeEpisode {
|
||||
videoId: number;
|
||||
episode: number | null;
|
||||
season: number | null;
|
||||
durationMs: number;
|
||||
endedMediaMs: number | null;
|
||||
watched: number;
|
||||
canonicalTitle: string;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalTokensSeen: number;
|
||||
totalYomitanLookupCount: number;
|
||||
lastWatchedMs: number;
|
||||
}
|
||||
|
||||
export interface AnimeWord {
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
partOfSpeech: string | null;
|
||||
frequency: number;
|
||||
}
|
||||
|
||||
export interface StreakCalendarDay {
|
||||
epochDay: number;
|
||||
totalActiveMin: number;
|
||||
}
|
||||
|
||||
export interface EpisodesPerDay {
|
||||
epochDay: number;
|
||||
episodeCount: number;
|
||||
}
|
||||
|
||||
export interface NewAnimePerDay {
|
||||
epochDay: number;
|
||||
newAnimeCount: number;
|
||||
}
|
||||
|
||||
export interface WatchTimePerAnime {
|
||||
epochDay: number;
|
||||
animeId: number;
|
||||
animeTitle: string;
|
||||
totalActiveMin: number;
|
||||
}
|
||||
|
||||
export interface TrendChartPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TrendPerAnimePoint {
|
||||
epochDay: number;
|
||||
animeTitle: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface LibrarySummaryRow {
|
||||
title: string;
|
||||
watchTimeMin: number;
|
||||
videos: number;
|
||||
sessions: number;
|
||||
cards: number;
|
||||
words: number;
|
||||
lookups: number;
|
||||
lookupsPerHundred: number | null;
|
||||
firstWatched: number;
|
||||
lastWatched: number;
|
||||
}
|
||||
|
||||
export interface TrendsDashboardData {
|
||||
activity: {
|
||||
watchTime: TrendChartPoint[];
|
||||
cards: TrendChartPoint[];
|
||||
words: TrendChartPoint[];
|
||||
sessions: TrendChartPoint[];
|
||||
};
|
||||
progress: {
|
||||
watchTime: TrendChartPoint[];
|
||||
sessions: TrendChartPoint[];
|
||||
words: TrendChartPoint[];
|
||||
newWords: TrendChartPoint[];
|
||||
cards: TrendChartPoint[];
|
||||
episodes: TrendChartPoint[];
|
||||
lookups: TrendChartPoint[];
|
||||
};
|
||||
ratios: {
|
||||
lookupsPerHundred: TrendChartPoint[];
|
||||
cardsPerHour: TrendChartPoint[];
|
||||
readingSpeed: TrendChartPoint[];
|
||||
};
|
||||
librarySummary: LibrarySummaryRow[];
|
||||
animeCumulative: {
|
||||
watchTime: TrendPerAnimePoint[];
|
||||
episodes: TrendPerAnimePoint[];
|
||||
cards: TrendPerAnimePoint[];
|
||||
words: TrendPerAnimePoint[];
|
||||
};
|
||||
patterns: {
|
||||
watchTimeByDayOfWeek: TrendChartPoint[];
|
||||
watchTimeByHour: TrendChartPoint[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface WordDetailData {
|
||||
detail: {
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
partOfSpeech: string | null;
|
||||
pos1: string | null;
|
||||
pos2: string | null;
|
||||
pos3: string | null;
|
||||
frequency: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
animeAppearances: Array<{
|
||||
animeId: number;
|
||||
animeTitle: string;
|
||||
occurrenceCount: number;
|
||||
}>;
|
||||
similarWords: Array<{
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
frequency: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface EpisodeCardEvent {
|
||||
eventId: number;
|
||||
sessionId: number;
|
||||
tsMs: number;
|
||||
cardsDelta: number;
|
||||
noteIds: number[];
|
||||
}
|
||||
|
||||
export interface EpisodeDetailData {
|
||||
sessions: SessionSummary[];
|
||||
words: AnimeWord[];
|
||||
cardEvents: EpisodeCardEvent[];
|
||||
}
|
||||
|
||||
export interface KanjiDetailData {
|
||||
detail: {
|
||||
kanjiId: number;
|
||||
kanji: string;
|
||||
frequency: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
animeAppearances: Array<{
|
||||
animeId: number;
|
||||
animeTitle: string;
|
||||
occurrenceCount: number;
|
||||
}>;
|
||||
words: Array<{
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
frequency: number;
|
||||
}>;
|
||||
}
|
||||
export * from '../../../src/types/stats-wire';
|
||||
export * from '../../../src/types/stats-http-contract';
|
||||
|
||||
Reference in New Issue
Block a user