mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
feat(stats): add sentence search tab and fix mining card behavior
- Add Search tab with realtime subtitle sentence search, media context, and mining actions - Persist library card size selection across reloads - Fix selection text written only for sentence cards, not word/audio cards - Fix word mining audio routed to SentenceAudio field when present - Fix sentence card created immediately before slow media generation completes - Fix Anki config resolved at request time for sentence mining - Prefer non-Signs/Songs tracks when auto-selecting secondary subtitle language
This commit is contained in:
@@ -30,6 +30,11 @@ const VocabularyTab = lazy(() =>
|
||||
default: module.VocabularyTab,
|
||||
})),
|
||||
);
|
||||
const SearchTab = lazy(() =>
|
||||
import('./components/search/SearchTab').then((module) => ({
|
||||
default: module.SearchTab,
|
||||
})),
|
||||
);
|
||||
const SessionsTab = lazy(() =>
|
||||
import('./components/sessions/SessionsTab').then((module) => ({
|
||||
default: module.SessionsTab,
|
||||
@@ -239,6 +244,19 @@ export function App() {
|
||||
</Suspense>
|
||||
</section>
|
||||
) : null}
|
||||
{mountedTabs.has('search') ? (
|
||||
<section
|
||||
id="panel-search"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-search"
|
||||
hidden={activeTab !== 'search'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<Suspense fallback={<LoadingSurface label="Loading search..." />}>
|
||||
<SearchTab />
|
||||
</Suspense>
|
||||
</section>
|
||||
) : null}
|
||||
{mountedTabs.has('sessions') ? (
|
||||
<section
|
||||
id="panel-sessions"
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import {
|
||||
getLibraryCardSizeStorage,
|
||||
readLibraryCardSizePreference,
|
||||
type LibraryCardSize,
|
||||
writeLibraryCardSizePreference,
|
||||
} from '../../lib/library-card-size';
|
||||
import { AnimeCard } from './AnimeCard';
|
||||
import { AnimeDetailView } from './AnimeDetailView';
|
||||
|
||||
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
||||
type CardSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
const GRID_CLASSES: Record<CardSize, string> = {
|
||||
const GRID_CLASSES: Record<LibraryCardSize, string> = {
|
||||
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
|
||||
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
|
||||
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
|
||||
@@ -51,9 +56,21 @@ export function AnimeTab({
|
||||
const { anime, loading, error } = useAnimeLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||
const [cardSize, setCardSize] = useState<CardSize>('md');
|
||||
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
|
||||
readLibraryCardSizePreference(
|
||||
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
|
||||
),
|
||||
);
|
||||
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
||||
|
||||
function handleCardSizeChange(size: LibraryCardSize): void {
|
||||
setCardSize(size);
|
||||
writeLibraryCardSizePreference(
|
||||
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
|
||||
size,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initialAnimeId != null) {
|
||||
setSelectedAnimeId(initialAnimeId);
|
||||
@@ -113,7 +130,7 @@ export function AnimeTab({
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setCardSize(size)}
|
||||
onClick={() => handleCardSizeChange(size)}
|
||||
className={`px-2 py-1 rounded-md text-xs transition-colors ${
|
||||
cardSize === size
|
||||
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, type KeyboardEvent } from 'react';
|
||||
|
||||
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions';
|
||||
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'search' | 'sessions';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
@@ -12,6 +12,7 @@ const TABS: Tab[] = [
|
||||
{ id: 'anime', label: 'Library' },
|
||||
{ id: 'trends', label: 'Trends' },
|
||||
{ id: 'vocabulary', label: 'Vocabulary' },
|
||||
{ id: 'search', label: 'Search' },
|
||||
{ id: 'sessions', label: 'Sessions' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SEARCH_TAB_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'SearchTab.tsx');
|
||||
|
||||
test('SearchTab forwards stored secondary subtitle text when mining from search results', () => {
|
||||
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
|
||||
|
||||
assert.match(source, /secondaryText:\s*result\.secondaryText/);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import {
|
||||
getSentenceSearchMineAvailability,
|
||||
renderSentenceWithMatches,
|
||||
} from '../../lib/sentence-search';
|
||||
import type { SentenceSearchResult } from '../../types/stats';
|
||||
|
||||
const SEARCH_LIMIT = 50;
|
||||
const SEARCH_DEBOUNCE_MS = 160;
|
||||
|
||||
type MineMode = 'word' | 'sentence' | 'audio';
|
||||
type MineStatus = { loading?: boolean; success?: boolean; error?: string };
|
||||
|
||||
function formatSegment(ms: number | null): string {
|
||||
if (ms == null || !Number.isFinite(ms)) return '--:--';
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function resultKey(result: SentenceSearchResult, index: number): string {
|
||||
return `${result.sessionId}-${result.lineIndex}-${result.segmentStartMs ?? index}`;
|
||||
}
|
||||
|
||||
function statusKey(result: SentenceSearchResult, index: number, mode: MineMode): string {
|
||||
return `${resultKey(result, index)}-${mode}`;
|
||||
}
|
||||
|
||||
function buttonLabel(
|
||||
mode: MineMode,
|
||||
status: MineStatus | undefined,
|
||||
disabledLabel: string,
|
||||
): string {
|
||||
if (status?.loading) return 'Mining...';
|
||||
if (status?.success) return 'Mined!';
|
||||
if (disabledLabel) return disabledLabel;
|
||||
if (mode === 'word') return 'Word';
|
||||
if (mode === 'audio') return 'Audio';
|
||||
return 'Sentence';
|
||||
}
|
||||
|
||||
export function SearchTab() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SentenceSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
|
||||
const requestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
const requestId = ++requestRef.current;
|
||||
setMineStatus({});
|
||||
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const timer = window.setTimeout(() => {
|
||||
apiClient
|
||||
.searchSentences(trimmed, SEARCH_LIMIT)
|
||||
.then((nextResults) => {
|
||||
if (requestId !== requestRef.current) return;
|
||||
setResults(nextResults);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (requestId !== requestRef.current) return;
|
||||
setError(err.message);
|
||||
setResults([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId !== requestRef.current) return;
|
||||
setLoading(false);
|
||||
});
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
const handleMine = async (
|
||||
result: SentenceSearchResult,
|
||||
index: number,
|
||||
mode: MineMode,
|
||||
): Promise<void> => {
|
||||
const availability = getSentenceSearchMineAvailability(result, query);
|
||||
if (mode === 'sentence' ? !availability.canMineSentence : !availability.canMineWordAudio) {
|
||||
return;
|
||||
}
|
||||
if (!result.sourcePath || result.segmentStartMs == null || result.segmentEndMs == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = statusKey(result, index, mode);
|
||||
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
|
||||
try {
|
||||
const searchedWord = availability.exactMatch ? query.trim() : '';
|
||||
const response = await apiClient.mineCard({
|
||||
sourcePath: result.sourcePath,
|
||||
startMs: result.segmentStartMs,
|
||||
endMs: result.segmentEndMs,
|
||||
sentence: result.text,
|
||||
word: searchedWord,
|
||||
secondaryText: result.secondaryText,
|
||||
videoTitle: result.videoTitle,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setMineStatus((prev) => ({ ...prev, [key]: { error: response.error } }));
|
||||
return;
|
||||
}
|
||||
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
|
||||
} catch (err) {
|
||||
setMineStatus((prev) => ({
|
||||
...prev,
|
||||
[key]: { error: err instanceof Error ? err.message : String(err) },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/70 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<label className="min-w-0 flex-1">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||
Sentence Search
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search sentence text or media..."
|
||||
className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 text-base text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-yellow focus:outline-none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2 text-right">
|
||||
<div className="text-xl font-semibold text-ctp-yellow">
|
||||
{loading ? '...' : results.length}
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-ctp-overlay1">Matches</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-ctp-red/40 bg-ctp-red/10 p-3 text-sm text-ctp-red">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!trimmedQuery && (
|
||||
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
|
||||
Search your tracked subtitle lines.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trimmedQuery && !loading && !error && results.length === 0 && (
|
||||
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
|
||||
No sentence matches.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="grid gap-3">
|
||||
{results.map((result, index) => {
|
||||
const availability = getSentenceSearchMineAvailability(result, trimmedQuery);
|
||||
const wordStatus = mineStatus[statusKey(result, index, 'word')];
|
||||
const sentenceStatus = mineStatus[statusKey(result, index, 'sentence')];
|
||||
const audioStatus = mineStatus[statusKey(result, index, 'audio')];
|
||||
const wordAudioDisabledReason =
|
||||
availability.unavailableReason ??
|
||||
(availability.exactMatch ? '' : 'Exact searched word not found in sentence.');
|
||||
const sentenceDisabledReason = availability.unavailableReason ?? '';
|
||||
const errors = [wordStatus?.error, sentenceStatus?.error, audioStatus?.error].filter(
|
||||
Boolean,
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={resultKey(result, index)}
|
||||
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/90 p-4 shadow-lg shadow-ctp-crust/20"
|
||||
>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-ctp-text">
|
||||
{result.animeTitle ?? result.videoTitle}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-ctp-overlay1">
|
||||
<span className="max-w-full truncate">{result.videoTitle}</span>
|
||||
<span>line {result.lineIndex}</span>
|
||||
<span>session {result.sessionId}</span>
|
||||
<span>
|
||||
{formatSegment(result.segmentStartMs)}-{formatSegment(result.segmentEndMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availability.exactMatch && (
|
||||
<button
|
||||
type="button"
|
||||
title={wordAudioDisabledReason || 'Create a word card from this sentence'}
|
||||
className="rounded-md border border-ctp-mauve/50 px-3 py-1.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-mauve/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||
disabled={wordStatus?.loading || !availability.canMineWordAudio}
|
||||
onClick={() => void handleMine(result, index, 'word')}
|
||||
>
|
||||
{buttonLabel(
|
||||
'word',
|
||||
wordStatus,
|
||||
availability.canMineWordAudio ? '' : 'Unavailable',
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title={sentenceDisabledReason || 'Create a sentence card from this line'}
|
||||
className="rounded-md border border-ctp-green/50 px-3 py-1.5 text-xs font-medium text-ctp-green transition hover:bg-ctp-green/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||
disabled={sentenceStatus?.loading || !availability.canMineSentence}
|
||||
onClick={() => void handleMine(result, index, 'sentence')}
|
||||
>
|
||||
{buttonLabel(
|
||||
'sentence',
|
||||
sentenceStatus,
|
||||
availability.canMineSentence ? '' : 'Unavailable',
|
||||
)}
|
||||
</button>
|
||||
{availability.exactMatch && (
|
||||
<button
|
||||
type="button"
|
||||
title={wordAudioDisabledReason || 'Create an audio card from this sentence'}
|
||||
className="rounded-md border border-ctp-blue/50 px-3 py-1.5 text-xs font-medium text-ctp-blue transition hover:bg-ctp-blue/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||
disabled={audioStatus?.loading || !availability.canMineWordAudio}
|
||||
onClick={() => void handleMine(result, index, 'audio')}
|
||||
>
|
||||
{buttonLabel(
|
||||
'audio',
|
||||
audioStatus,
|
||||
availability.canMineWordAudio ? '' : 'Unavailable',
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 rounded-lg bg-ctp-base/70 px-4 py-3 text-base leading-7 text-ctp-text">
|
||||
{renderSentenceWithMatches(result.text, trimmedQuery)}
|
||||
</p>
|
||||
{errors.length > 0 && <div className="mt-2 text-xs text-ctp-red">{errors[0]}</div>}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,6 @@ export function VocabularyTab({
|
||||
}: VocabularyTabProps) {
|
||||
const { words, kanji, knownWords, loading, error } = useVocabulary();
|
||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [hideNames, setHideNames] = useState(false);
|
||||
const [showExclusionManager, setShowExclusionManager] = useState(false);
|
||||
|
||||
@@ -116,14 +115,7 @@ export function VocabularyTab({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search words..."
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{hasNames && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -178,12 +170,7 @@ export function VocabularyTab({
|
||||
onSelectWord={handleSelectWord}
|
||||
/>
|
||||
|
||||
<WordList
|
||||
words={filteredWords}
|
||||
selectedKey={null}
|
||||
onSelectWord={handleSelectWord}
|
||||
search={search}
|
||||
/>
|
||||
<WordList words={filteredWords} selectedKey={null} onSelectWord={handleSelectWord} />
|
||||
|
||||
<KanjiBreakdown
|
||||
kanji={kanji}
|
||||
|
||||
@@ -179,15 +179,15 @@ export function WordDetailPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40">
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close word detail panel"
|
||||
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||
<div className="flex h-full flex-col">
|
||||
<aside className="relative flex max-h-[85vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||
|
||||
@@ -22,7 +22,7 @@ export function posColor(pos: string): string {
|
||||
|
||||
export function PosBadge({ pos }: { pos: string }) {
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${posColor(pos)}`}>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${posColor(pos)}`}>
|
||||
{pos.replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -88,6 +88,28 @@ test('deleteSession sends a DELETE request to the session endpoint', async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('searchSentences encodes realtime sentence search requests', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
seenUrl = String(input);
|
||||
return new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.searchSentences('猫 食べる', 25);
|
||||
assert.equal(
|
||||
seenUrl,
|
||||
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25`,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSession throws when the stats API delete request fails', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SessionTimelinePoint,
|
||||
SessionEvent,
|
||||
VocabularyEntry,
|
||||
SentenceSearchResult,
|
||||
KanjiEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
MediaLibraryItem,
|
||||
@@ -115,6 +116,13 @@ export const apiClient = {
|
||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
searchSentences: (query: string, limit = 50) =>
|
||||
fetchJson<SentenceSearchResult[]>(
|
||||
`/api/stats/sentences/search?${new URLSearchParams({
|
||||
q: query,
|
||||
limit: String(limit),
|
||||
}).toString()}`,
|
||||
),
|
||||
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
|
||||
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||
|
||||
@@ -13,6 +13,7 @@ test('App lazy-loads non-overview tabs and detail surfaces behind Suspense bound
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/anime\/AnimeTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/VocabularyTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/search\/SearchTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/sessions\/SessionsTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/);
|
||||
@@ -23,6 +24,7 @@ test('App lazy-loads non-overview tabs and detail surfaces behind Suspense bound
|
||||
source,
|
||||
/import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
|
||||
);
|
||||
assert.doesNotMatch(source, /import \{ SearchTab \} from '\.\/components\/search\/SearchTab';/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DEFAULT_LIBRARY_CARD_SIZE,
|
||||
LIBRARY_CARD_SIZE_STORAGE_KEY,
|
||||
getLibraryCardSizeStorage,
|
||||
readLibraryCardSizePreference,
|
||||
writeLibraryCardSizePreference,
|
||||
} from './library-card-size';
|
||||
|
||||
function createStorage(initial: Record<string, string | null> = {}): Storage {
|
||||
const values = new Map(
|
||||
Object.entries(initial).filter((entry): entry is [string, string] => {
|
||||
return entry[1] !== null;
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return values.get(key) ?? null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(values.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('readLibraryCardSizePreference returns saved valid sizes', () => {
|
||||
const storage = createStorage({ [LIBRARY_CARD_SIZE_STORAGE_KEY]: 'lg' });
|
||||
|
||||
assert.equal(readLibraryCardSizePreference(storage), 'lg');
|
||||
});
|
||||
|
||||
test('readLibraryCardSizePreference falls back for missing or invalid saved sizes', () => {
|
||||
assert.equal(readLibraryCardSizePreference(createStorage()), DEFAULT_LIBRARY_CARD_SIZE);
|
||||
assert.equal(
|
||||
readLibraryCardSizePreference(createStorage({ [LIBRARY_CARD_SIZE_STORAGE_KEY]: 'xl' })),
|
||||
DEFAULT_LIBRARY_CARD_SIZE,
|
||||
);
|
||||
});
|
||||
|
||||
test('library card size preference helpers ignore storage failures', () => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
} as unknown as Storage;
|
||||
|
||||
assert.equal(readLibraryCardSizePreference(storage), DEFAULT_LIBRARY_CARD_SIZE);
|
||||
assert.doesNotThrow(() => writeLibraryCardSizePreference(storage, 'sm'));
|
||||
});
|
||||
|
||||
test('getLibraryCardSizeStorage returns null when localStorage access is blocked', () => {
|
||||
const source = {
|
||||
get localStorage(): Storage {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(getLibraryCardSizeStorage(source), null);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export type LibraryCardSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export const DEFAULT_LIBRARY_CARD_SIZE: LibraryCardSize = 'md';
|
||||
export const LIBRARY_CARD_SIZE_STORAGE_KEY = 'subminer.stats.library.cardSize';
|
||||
|
||||
export function getLibraryCardSizeStorage(
|
||||
source: { localStorage: Storage } | null | undefined,
|
||||
): Storage | null {
|
||||
try {
|
||||
return source?.localStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readLibraryCardSizePreference(
|
||||
storage: Storage | null | undefined,
|
||||
): LibraryCardSize {
|
||||
try {
|
||||
const value = storage?.getItem(LIBRARY_CARD_SIZE_STORAGE_KEY);
|
||||
return value === 'sm' || value === 'md' || value === 'lg' ? value : DEFAULT_LIBRARY_CARD_SIZE;
|
||||
} catch {
|
||||
return DEFAULT_LIBRARY_CARD_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLibraryCardSizePreference(
|
||||
storage: Storage | null | undefined,
|
||||
size: LibraryCardSize,
|
||||
): void {
|
||||
try {
|
||||
storage?.setItem(LIBRARY_CARD_SIZE_STORAGE_KEY, size);
|
||||
} catch {
|
||||
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
findExactSentenceMatches,
|
||||
getSentenceSearchMineAvailability,
|
||||
renderSentenceWithMatches,
|
||||
} from './sentence-search';
|
||||
import type { SentenceSearchResult } from '../types/stats';
|
||||
|
||||
function makeResult(over: Partial<SentenceSearchResult>): SentenceSearchResult {
|
||||
return {
|
||||
animeId: null,
|
||||
animeTitle: null,
|
||||
videoId: 1,
|
||||
videoTitle: 'Episode 1',
|
||||
sourcePath: '/tmp/video.mkv',
|
||||
secondaryText: null,
|
||||
sessionId: 10,
|
||||
lineIndex: 3,
|
||||
segmentStartMs: 1000,
|
||||
segmentEndMs: 2500,
|
||||
text: '猫が猫を見た',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('findExactSentenceMatches returns every exact searched-word range', () => {
|
||||
assert.deepEqual(findExactSentenceMatches('猫が猫を見た', '猫'), [
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 2, end: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('getSentenceSearchMineAvailability gates word and audio mining on exact sentence match', () => {
|
||||
const result = makeResult({});
|
||||
|
||||
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
|
||||
canMineSentence: true,
|
||||
canMineWordAudio: true,
|
||||
exactMatch: true,
|
||||
unavailableReason: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(getSentenceSearchMineAvailability(result, '犬'), {
|
||||
canMineSentence: true,
|
||||
canMineWordAudio: false,
|
||||
exactMatch: false,
|
||||
unavailableReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('getSentenceSearchMineAvailability disables every mining mode without source timing', () => {
|
||||
const result = makeResult({ sourcePath: null, segmentEndMs: null });
|
||||
|
||||
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
|
||||
canMineSentence: false,
|
||||
canMineWordAudio: false,
|
||||
exactMatch: true,
|
||||
unavailableReason: 'This source has no local file path.',
|
||||
});
|
||||
});
|
||||
|
||||
test('renderSentenceWithMatches highlights exact searched-word matches', () => {
|
||||
const markup = renderToStaticMarkup(<>{renderSentenceWithMatches('猫が寝る', '猫')}</>);
|
||||
|
||||
assert.match(markup, /<mark/);
|
||||
assert.match(markup, />猫<\/mark>/);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SentenceSearchResult } from '../types/stats';
|
||||
|
||||
export interface SentenceMatchRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SentenceSearchMineAvailability {
|
||||
canMineSentence: boolean;
|
||||
canMineWordAudio: boolean;
|
||||
exactMatch: boolean;
|
||||
unavailableReason: string | null;
|
||||
}
|
||||
|
||||
function normalizedSearchWord(query: string): string {
|
||||
return query.trim();
|
||||
}
|
||||
|
||||
export function findExactSentenceMatches(text: string, query: string): SentenceMatchRange[] {
|
||||
const needle = normalizedSearchWord(query);
|
||||
if (!needle) return [];
|
||||
|
||||
const ranges: SentenceMatchRange[] = [];
|
||||
const haystack = text.toLocaleLowerCase();
|
||||
const normalizedNeedle = needle.toLocaleLowerCase();
|
||||
let searchFrom = 0;
|
||||
|
||||
while (searchFrom < haystack.length) {
|
||||
const index = haystack.indexOf(normalizedNeedle, searchFrom);
|
||||
if (index < 0) break;
|
||||
ranges.push({ start: index, end: index + needle.length });
|
||||
searchFrom = index + needle.length;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function getSentenceSearchMineAvailability(
|
||||
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text'>,
|
||||
query: string,
|
||||
): SentenceSearchMineAvailability {
|
||||
const exactMatch = findExactSentenceMatches(result.text, query).length > 0;
|
||||
const unavailableReason = !result.sourcePath
|
||||
? 'This source has no local file path.'
|
||||
: result.segmentStartMs == null || result.segmentEndMs == null
|
||||
? 'This line is missing segment timing.'
|
||||
: null;
|
||||
|
||||
return {
|
||||
canMineSentence: unavailableReason === null,
|
||||
canMineWordAudio: unavailableReason === null && exactMatch,
|
||||
exactMatch,
|
||||
unavailableReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSentenceWithMatches(text: string, query: string): ReactNode {
|
||||
const ranges = findExactSentenceMatches(text, query);
|
||||
if (ranges.length === 0) return text;
|
||||
|
||||
const parts: ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
ranges.forEach((range, index) => {
|
||||
if (range.start > cursor) {
|
||||
parts.push(text.slice(cursor, range.start));
|
||||
}
|
||||
parts.push(
|
||||
<mark
|
||||
key={`${range.start}-${index}`}
|
||||
className="rounded bg-ctp-yellow/15 px-0.5 text-ctp-yellow underline decoration-ctp-yellow/60 underline-offset-2"
|
||||
>
|
||||
{text.slice(range.start, range.end)}
|
||||
</mark>,
|
||||
);
|
||||
cursor = range.end;
|
||||
});
|
||||
|
||||
if (cursor < text.length) {
|
||||
parts.push(text.slice(cursor));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ test('TabBar renders Library instead of Anime for the media library tab', () =>
|
||||
assert.doesNotMatch(markup, />Anime</);
|
||||
assert.match(markup, />Overview</);
|
||||
assert.match(markup, />Library</);
|
||||
assert.match(markup, />Search</);
|
||||
});
|
||||
|
||||
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
|
||||
|
||||
@@ -115,6 +115,20 @@ export interface VocabularyOccurrenceEntry {
|
||||
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[];
|
||||
|
||||
Reference in New Issue
Block a user