mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
83897c81d1
- Search by headword enabled by default; finds inflected variants (e.g. 知らない → 知らねえ) - Add "Search by headword" toggle to switch back to exact text/title matching - Rename "Similar Words" → "Related Seen Words" with tighter matching (same reading/shared kanji) - ankiConnect.deck falls back to Yomitan mining deck when empty
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
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 formatSentenceSearchMatchCountLabel(count: number): string {
|
|
return count === 1 ? 'Match' : 'Matches';
|
|
}
|
|
|
|
export function SearchTab() {
|
|
const [query, setQuery] = useState('');
|
|
const [searchByHeadword, setSearchByHeadword] = useState(true);
|
|
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, searchByHeadword)
|
|
.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, searchByHeadword]);
|
|
|
|
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">
|
|
<span className="mb-3 block text-xs font-semibold uppercase tracking-[0.18em] text-ctp-overlay1">
|
|
Sentence Search
|
|
</span>
|
|
<div className="flex items-stretch gap-3">
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search sentence text or media..."
|
|
className="min-w-0 flex-1 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"
|
|
aria-label="Sentence search"
|
|
/>
|
|
<div className="flex min-w-[5rem] flex-col items-center justify-center rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2">
|
|
<div className="text-xl font-semibold leading-none text-ctp-yellow">
|
|
{loading ? '...' : results.length}
|
|
</div>
|
|
<div className="mt-1 text-[11px] uppercase tracking-wide text-ctp-overlay1">
|
|
{loading ? 'Matches' : formatSentenceSearchMatchCountLabel(results.length)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<label className="mt-3 inline-flex cursor-pointer items-center gap-2 text-xs font-medium text-ctp-overlay1">
|
|
<input
|
|
type="checkbox"
|
|
checked={searchByHeadword}
|
|
onChange={(event) => setSearchByHeadword(event.target.checked)}
|
|
className="h-4 w-4 rounded border-ctp-surface2 bg-ctp-surface0 text-ctp-yellow focus:ring-ctp-yellow"
|
|
/>
|
|
Search by headword
|
|
</label>
|
|
</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>
|
|
);
|
|
}
|