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:
+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'>,
|
||||
|
||||
Reference in New Issue
Block a user