mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat: improve stats dashboard and annotation settings
This commit is contained in:
67
stats/src/lib/api-client.test.ts
Normal file
67
stats/src/lib/api-client.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client';
|
||||
|
||||
test('resolveStatsBaseUrl prefers apiBase query parameter for file-based overlay mode', () => {
|
||||
const baseUrl = resolveStatsBaseUrl({
|
||||
protocol: 'file:',
|
||||
origin: 'null',
|
||||
search: '?overlay=1&apiBase=http%3A%2F%2F127.0.0.1%3A6123',
|
||||
});
|
||||
|
||||
assert.equal(baseUrl, 'http://127.0.0.1:6123');
|
||||
});
|
||||
|
||||
test('resolveStatsBaseUrl falls back to configured window origin for browser mode', () => {
|
||||
const baseUrl = resolveStatsBaseUrl({
|
||||
protocol: 'http:',
|
||||
origin: 'http://127.0.0.1:6123',
|
||||
search: '',
|
||||
});
|
||||
|
||||
assert.equal(baseUrl, 'http://127.0.0.1:6123');
|
||||
});
|
||||
|
||||
test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without apiBase', () => {
|
||||
const baseUrl = resolveStatsBaseUrl({
|
||||
protocol: 'file:',
|
||||
origin: 'null',
|
||||
search: '?overlay=1',
|
||||
});
|
||||
|
||||
assert.equal(baseUrl, 'http://127.0.0.1:6969');
|
||||
});
|
||||
|
||||
test('deleteSession sends a DELETE request to the session endpoint', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
let seenMethod = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seenUrl = String(input);
|
||||
seenMethod = init?.method ?? 'GET';
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.deleteSession(42);
|
||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42`);
|
||||
assert.equal(seenMethod, 'DELETE');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSession throws when the stats API delete request fails', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response('boom', {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
})) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(() => apiClient.deleteSession(7), /Stats API error: 500 boom/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -22,12 +22,27 @@ import type {
|
||||
EpisodeDetailData,
|
||||
} from '../types/stats';
|
||||
|
||||
export const BASE_URL = window.location.protocol === 'file:'
|
||||
? 'http://127.0.0.1:5175'
|
||||
: window.location.origin;
|
||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`);
|
||||
export function resolveStatsBaseUrl(location?: StatsLocationLike): string {
|
||||
const resolvedLocation =
|
||||
location ??
|
||||
(typeof window === 'undefined'
|
||||
? { protocol: 'file:', origin: 'null', search: '' }
|
||||
: window.location);
|
||||
|
||||
const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim();
|
||||
if (queryApiBase) {
|
||||
return queryApiBase;
|
||||
}
|
||||
|
||||
return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin;
|
||||
}
|
||||
|
||||
export const BASE_URL = resolveStatsBaseUrl();
|
||||
|
||||
async function fetchResponse(path: string, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, init);
|
||||
if (!res.ok) {
|
||||
let body = '';
|
||||
try {
|
||||
@@ -39,6 +54,11 @@ async function fetchJson<T>(path: string): Promise<T> {
|
||||
body ? `Stats API error: ${res.status} ${body}` : `Stats API error: ${res.status}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const res = await fetchResponse(path);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -55,13 +75,7 @@ export const apiClient = {
|
||||
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
|
||||
getVocabulary: (limit = 100) =>
|
||||
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
|
||||
getWordOccurrences: (
|
||||
headword: string,
|
||||
word: string,
|
||||
reading: string,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
) =>
|
||||
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
|
||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
@@ -71,11 +85,9 @@ export const apiClient = {
|
||||
`/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}`),
|
||||
getMediaDetail: (videoId: number) => fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
|
||||
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
|
||||
getAnimeDetail: (animeId: number) =>
|
||||
fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
|
||||
getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
|
||||
getAnimeWords: (animeId: number, limit = 50) =>
|
||||
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||
getAnimeRollups: (animeId: number, limit = 90) =>
|
||||
@@ -96,16 +108,54 @@ export const apiClient = {
|
||||
getEpisodeDetail: (videoId: number) =>
|
||||
fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
|
||||
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
|
||||
await fetch(`${BASE_URL}/api/stats/media/${videoId}/watched`, {
|
||||
await fetchResponse(`/api/stats/media/${videoId}/watched`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ watched }),
|
||||
});
|
||||
},
|
||||
ankiBrowse: async (noteId: number): Promise<void> => {
|
||||
await fetch(`${BASE_URL}/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
|
||||
deleteSession: async (sessionId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
},
|
||||
ankiNotesInfo: async (noteIds: number[]): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => {
|
||||
deleteVideo: async (videoId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
|
||||
},
|
||||
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
|
||||
searchAnilist: (query: string) =>
|
||||
fetchJson<
|
||||
Array<{
|
||||
id: number;
|
||||
episodes: number | null;
|
||||
season: string | null;
|
||||
seasonYear: number | 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> => {
|
||||
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(info),
|
||||
});
|
||||
},
|
||||
ankiBrowse: async (noteId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
|
||||
},
|
||||
ankiNotesInfo: async (
|
||||
noteIds: number[],
|
||||
): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => {
|
||||
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
35
stats/src/lib/delete-confirm.test.ts
Normal file
35
stats/src/lib/delete-confirm.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
|
||||
|
||||
test('confirmSessionDelete uses the shared session delete warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
calls.push(message ?? '');
|
||||
return true;
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(confirmSessionDelete(), true);
|
||||
assert.deepEqual(calls, ['Delete this session and all associated data?']);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
calls.push(message ?? '');
|
||||
return false;
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(confirmEpisodeDelete('Episode 4'), false);
|
||||
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
7
stats/src/lib/delete-confirm.ts
Normal file
7
stats/src/lib/delete-confirm.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function confirmSessionDelete(): boolean {
|
||||
return globalThis.confirm('Delete this session and all associated data?');
|
||||
}
|
||||
|
||||
export function confirmEpisodeDelete(title: string): boolean {
|
||||
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
|
||||
}
|
||||
Reference in New Issue
Block a user