mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(stats): fix truncated readings and improve word detail UX
- fullReading() reconstructs full word reading from headword + partial stored reading - FrequencyRankTable always shows reading for every row - Word highlighted in example sentences with underline style - Bar chart clicks open word detail panel
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { PosBadge } from './pos-helpers';
|
import { PosBadge } from './pos-helpers';
|
||||||
|
import { fullReading } from '../../lib/reading-utils';
|
||||||
import type { VocabularyEntry } from '../../types/stats';
|
import type { VocabularyEntry } from '../../types/stats';
|
||||||
|
|
||||||
interface FrequencyRankTableProps {
|
interface FrequencyRankTableProps {
|
||||||
@@ -13,11 +14,12 @@ const PAGE_SIZE = 25;
|
|||||||
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
|
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [hideKnown, setHideKnown] = useState(true);
|
const [hideKnown, setHideKnown] = useState(true);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const hasKnownData = knownWords.size > 0;
|
const hasKnownData = knownWords.size > 0;
|
||||||
|
|
||||||
const isWordKnown = (w: VocabularyEntry): boolean => {
|
const isWordKnown = (w: VocabularyEntry): boolean => {
|
||||||
return knownWords.has(w.headword) || knownWords.has(w.word) || knownWords.has(w.reading);
|
return knownWords.has(w.headword) || knownWords.has(w.word);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ranked = useMemo(() => {
|
const ranked = useMemo(() => {
|
||||||
@@ -25,7 +27,28 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
if (hideKnown && hasKnownData) {
|
if (hideKnown && hasKnownData) {
|
||||||
filtered = filtered.filter((w) => !isWordKnown(w));
|
filtered = filtered.filter((w) => !isWordKnown(w));
|
||||||
}
|
}
|
||||||
return filtered.sort((a, b) => a.frequencyRank! - b.frequencyRank!);
|
|
||||||
|
const byHeadword = new Map<string, VocabularyEntry>();
|
||||||
|
for (const w of filtered) {
|
||||||
|
const existing = byHeadword.get(w.headword);
|
||||||
|
if (!existing) {
|
||||||
|
byHeadword.set(w.headword, { ...w });
|
||||||
|
} else {
|
||||||
|
existing.frequency += w.frequency;
|
||||||
|
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
|
||||||
|
if (w.frequencyRank! < existing.frequencyRank!) {
|
||||||
|
existing.frequencyRank = w.frequencyRank;
|
||||||
|
}
|
||||||
|
if (!existing.reading && w.reading) {
|
||||||
|
existing.reading = w.reading;
|
||||||
|
}
|
||||||
|
if (!existing.partOfSpeech && w.partOfSpeech) {
|
||||||
|
existing.partOfSpeech = w.partOfSpeech;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
|
||||||
}, [words, knownWords, hideKnown, hasKnownData]);
|
}, [words, knownWords, hideKnown, hasKnownData]);
|
||||||
|
|
||||||
if (words.every((w) => w.frequencyRank == null)) {
|
if (words.every((w) => w.frequencyRank == null)) {
|
||||||
@@ -44,10 +67,15 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-ctp-text">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}>{'\u25B6'}</span>
|
||||||
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
|
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
|
||||||
</h3>
|
</button>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{hasKnownData && (
|
{hasKnownData && (
|
||||||
<button
|
<button
|
||||||
@@ -67,13 +95,13 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ranked.length === 0 ? (
|
{collapsed ? null : ranked.length === 0 ? (
|
||||||
<div className="text-xs text-ctp-overlay2">
|
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||||
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
|
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto mt-3">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||||
@@ -98,7 +126,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
{w.headword}
|
{w.headword}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-3 text-ctp-subtext0">
|
<td className="py-1.5 pr-3 text-ctp-subtext0">
|
||||||
{w.reading !== w.headword ? w.reading : ''}
|
{fullReading(w.headword, w.reading) || w.headword}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-3">
|
<td className="py-1.5 pr-3">
|
||||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { useWordDetail } from '../../hooks/useWordDetail';
|
import { useWordDetail } from '../../hooks/useWordDetail';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
|
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||||
|
import { fullReading } from '../../lib/reading-utils';
|
||||||
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
||||||
import { PosBadge } from './pos-helpers';
|
import { PosBadge } from './pos-helpers';
|
||||||
|
|
||||||
const OCCURRENCES_PAGE_SIZE = 50;
|
const INITIAL_PAGE_SIZE = 5;
|
||||||
|
const LOAD_MORE_SIZE = 10;
|
||||||
|
|
||||||
|
type MineStatus = { loading?: boolean; success?: boolean; error?: string };
|
||||||
|
|
||||||
interface WordDetailPanelProps {
|
interface WordDetailPanelProps {
|
||||||
wordId: number | null;
|
wordId: number | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelectWord?: (wordId: number) => void;
|
onSelectWord?: (wordId: number) => void;
|
||||||
onNavigateToAnime?: (animeId: number) => void;
|
onNavigateToAnime?: (animeId: number) => void;
|
||||||
|
isExcluded?: (w: { headword: string; word: string; reading: string }) => boolean;
|
||||||
|
onToggleExclusion?: (w: { headword: string; word: string; reading: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightWord(text: string, words: string[]): React.ReactNode {
|
||||||
|
const needles = words.filter(Boolean);
|
||||||
|
if (needles.length === 0) return text;
|
||||||
|
|
||||||
|
const escaped = needles.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
|
const pattern = new RegExp(`(${escaped.join('|')})`, 'g');
|
||||||
|
const parts = text.split(pattern);
|
||||||
|
const needleSet = new Set(needles);
|
||||||
|
|
||||||
|
return parts.map((part, i) =>
|
||||||
|
needleSet.has(part)
|
||||||
|
? <mark key={i} className="bg-transparent text-ctp-blue underline decoration-ctp-blue/40 underline-offset-2">{part}</mark>
|
||||||
|
: part
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSegment(ms: number | null): string {
|
function formatSegment(ms: number | null): string {
|
||||||
@@ -22,7 +44,7 @@ function formatSegment(ms: number | null): string {
|
|||||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAnime }: WordDetailPanelProps) {
|
export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAnime, isExcluded, onToggleExclusion }: WordDetailPanelProps) {
|
||||||
const { data, loading, error } = useWordDetail(wordId);
|
const { data, loading, error } = useWordDetail(wordId);
|
||||||
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
|
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
|
||||||
const [occLoading, setOccLoading] = useState(false);
|
const [occLoading, setOccLoading] = useState(false);
|
||||||
@@ -30,11 +52,23 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
|||||||
const [occError, setOccError] = useState<string | null>(null);
|
const [occError, setOccError] = useState<string | null>(null);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [occLoaded, setOccLoaded] = useState(false);
|
const [occLoaded, setOccLoaded] = useState(false);
|
||||||
|
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
|
||||||
const requestIdRef = useRef(0);
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOccurrences([]);
|
||||||
|
setOccLoaded(false);
|
||||||
|
setOccLoading(false);
|
||||||
|
setOccLoadingMore(false);
|
||||||
|
setOccError(null);
|
||||||
|
setHasMore(false);
|
||||||
|
setMineStatus({});
|
||||||
|
requestIdRef.current++;
|
||||||
|
}, [wordId]);
|
||||||
|
|
||||||
if (wordId === null) return null;
|
if (wordId === null) return null;
|
||||||
|
|
||||||
const loadOccurrences = async (detail: NonNullable<typeof data>['detail'], offset: number, append: boolean) => {
|
const loadOccurrences = async (detail: NonNullable<typeof data>['detail'], offset: number, limit: number, append: boolean) => {
|
||||||
const reqId = ++requestIdRef.current;
|
const reqId = ++requestIdRef.current;
|
||||||
if (append) {
|
if (append) {
|
||||||
setOccLoadingMore(true);
|
setOccLoadingMore(true);
|
||||||
@@ -45,11 +79,11 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
|||||||
try {
|
try {
|
||||||
const rows = await apiClient.getWordOccurrences(
|
const rows = await apiClient.getWordOccurrences(
|
||||||
detail.headword, detail.word, detail.reading,
|
detail.headword, detail.word, detail.reading,
|
||||||
OCCURRENCES_PAGE_SIZE, offset,
|
limit, offset,
|
||||||
);
|
);
|
||||||
if (reqId !== requestIdRef.current) return;
|
if (reqId !== requestIdRef.current) return;
|
||||||
setOccurrences(prev => append ? [...prev, ...rows] : rows);
|
setOccurrences(prev => append ? [...prev, ...rows] : rows);
|
||||||
setHasMore(rows.length === OCCURRENCES_PAGE_SIZE);
|
setHasMore(rows.length === limit);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (reqId !== requestIdRef.current) return;
|
if (reqId !== requestIdRef.current) return;
|
||||||
setOccError(err instanceof Error ? err.message : String(err));
|
setOccError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -67,12 +101,44 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
|||||||
|
|
||||||
const handleShowOccurrences = () => {
|
const handleShowOccurrences = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
void loadOccurrences(data.detail, 0, false);
|
void loadOccurrences(data.detail, 0, INITIAL_PAGE_SIZE, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
if (!data || occLoadingMore || !hasMore) return;
|
if (!data || occLoadingMore || !hasMore) return;
|
||||||
void loadOccurrences(data.detail, occurrences.length, true);
|
void loadOccurrences(data.detail, occurrences.length, LOAD_MORE_SIZE, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMine = async (occ: VocabularyOccurrenceEntry, mode: 'word' | 'sentence' | 'audio') => {
|
||||||
|
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
|
||||||
|
setMineStatus(prev => ({ ...prev, [key]: { loading: true } }));
|
||||||
|
try {
|
||||||
|
const result = await apiClient.mineCard({
|
||||||
|
sourcePath: occ.sourcePath!,
|
||||||
|
startMs: occ.segmentStartMs!,
|
||||||
|
endMs: occ.segmentEndMs!,
|
||||||
|
sentence: occ.text,
|
||||||
|
word: data!.detail.headword,
|
||||||
|
secondaryText: occ.secondaryText,
|
||||||
|
videoTitle: occ.videoTitle,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setMineStatus(prev => ({ ...prev, [key]: { error: result.error } }));
|
||||||
|
} else {
|
||||||
|
setMineStatus(prev => ({ ...prev, [key]: { success: true } }));
|
||||||
|
const label = mode === 'audio' ? 'Audio card' : mode === 'word' ? data!.detail.headword : occ.text.slice(0, 30);
|
||||||
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||||
|
new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' });
|
||||||
|
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
|
||||||
|
Notification.requestPermission().then(p => {
|
||||||
|
if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMineStatus(prev => ({ ...prev, [key]: { error: err instanceof Error ? err.message : String(err) } }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,7 +159,7 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
|||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">{data.detail.headword}</h2>
|
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">{data.detail.headword}</h2>
|
||||||
<div className="mt-1 text-sm text-ctp-subtext0">{data.detail.reading || data.detail.word}</div>
|
<div className="mt-1 text-sm text-ctp-subtext0">{fullReading(data.detail.headword, data.detail.reading) || data.detail.word}</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />}
|
{data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />}
|
||||||
{data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && (
|
{data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && (
|
||||||
@@ -109,6 +175,20 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{data && onToggleExclusion && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-md border px-3 py-1.5 text-xs font-medium transition ${
|
||||||
|
isExcluded?.(data.detail)
|
||||||
|
? 'border-ctp-red/50 bg-ctp-red/10 text-ctp-red hover:bg-ctp-red/20'
|
||||||
|
: 'border-ctp-surface2 text-ctp-subtext0 hover:border-ctp-red hover:text-ctp-red'
|
||||||
|
}`}
|
||||||
|
onClick={() => onToggleExclusion(data.detail)}
|
||||||
|
>
|
||||||
|
{isExcluded?.(data.detail) ? 'Excluded' : 'Exclude'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||||
@@ -117,6 +197,7 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
|||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||||
{data && (
|
{data && (
|
||||||
@@ -190,7 +271,7 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
|||||||
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>}
|
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>}
|
||||||
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
|
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
|
||||||
{occLoaded && !occLoading && occurrences.length === 0 && (
|
{occLoaded && !occLoading && occurrences.length === 0 && (
|
||||||
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
|
<div className="text-sm text-ctp-overlay2">No example lines tracked yet. Lines are stored for sessions recorded after the subtitle tracking update.</div>
|
||||||
)}
|
)}
|
||||||
{occurrences.length > 0 && (
|
{occurrences.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -212,23 +293,56 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
|||||||
{formatNumber(occ.occurrenceCount)} in line
|
{formatNumber(occ.occurrenceCount)} in line
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-xs text-ctp-overlay1">
|
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
|
||||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId}
|
<span>{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId}</span>
|
||||||
|
{occ.sourcePath && occ.segmentStartMs != null && occ.segmentEndMs != null && (() => {
|
||||||
|
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||||
|
const wordStatus = mineStatus[`${baseKey}-word`];
|
||||||
|
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
|
||||||
|
const audioStatus = mineStatus[`${baseKey}-audio`];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={wordStatus?.loading}
|
||||||
|
onClick={() => void handleMine(occ, 'word')}
|
||||||
|
>
|
||||||
|
{wordStatus?.loading ? 'Mining...' : wordStatus?.success ? 'Mined!' : 'Mine Word'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={sentenceStatus?.loading}
|
||||||
|
onClick={() => void handleMine(occ, 'sentence')}
|
||||||
|
>
|
||||||
|
{sentenceStatus?.loading ? 'Mining...' : sentenceStatus?.success ? 'Mined!' : 'Mine Sentence'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={audioStatus?.loading}
|
||||||
|
onClick={() => void handleMine(occ, 'audio')}
|
||||||
|
>
|
||||||
|
{audioStatus?.loading ? 'Mining...' : audioStatus?.success ? 'Mined!' : 'Mine Audio'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||||
|
const errors = ['word', 'sentence', 'audio']
|
||||||
|
.map(m => mineStatus[`${baseKey}-${m}`]?.error)
|
||||||
|
.filter(Boolean);
|
||||||
|
return errors.length > 0 ? <div className="mt-1 text-[10px] text-ctp-red">{errors[0]}</div> : null;
|
||||||
|
})()}
|
||||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||||
{occ.text}
|
{highlightWord(occ.text, [data!.detail.headword, data!.detail.word])}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
{hasMore && (
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{occLoaded && !occLoading && !occError && hasMore && (
|
|
||||||
<div className="border-t border-ctp-surface1 px-4 py-4">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
@@ -237,8 +351,13 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
|||||||
>
|
>
|
||||||
{occLoadingMore ? 'Loading more...' : 'Load more'}
|
{occLoadingMore ? 'Loading more...' : 'Load more'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
51
stats/src/lib/reading-utils.test.ts
Normal file
51
stats/src/lib/reading-utils.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fullReading } from './reading-utils';
|
||||||
|
|
||||||
|
describe('fullReading', () => {
|
||||||
|
it('prefixes leading hiragana from headword', () => {
|
||||||
|
// お前 with reading まえ → おまえ
|
||||||
|
expect(fullReading('お前', 'まえ')).toBe('おまえ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles katakana stored readings', () => {
|
||||||
|
// お前 with katakana reading マエ → おまえ
|
||||||
|
expect(fullReading('お前', 'マエ')).toBe('おまえ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns stored reading when it already includes leading kana', () => {
|
||||||
|
// Reading already correct
|
||||||
|
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles trailing hiragana', () => {
|
||||||
|
// 隠す with reading かくす — す is trailing hiragana
|
||||||
|
expect(fullReading('隠す', 'かくす')).toBe('かくす');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles pure kanji headwords', () => {
|
||||||
|
expect(fullReading('様', 'さま')).toBe('さま');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for empty reading', () => {
|
||||||
|
expect(fullReading('前', '')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for empty headword', () => {
|
||||||
|
expect(fullReading('', 'まえ')).toBe('まえ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles all-kana headword', () => {
|
||||||
|
// Headword is already all hiragana
|
||||||
|
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed leading and trailing kana', () => {
|
||||||
|
// お気に入り: お=leading, に入り=trailing around 気
|
||||||
|
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles katakana in headword', () => {
|
||||||
|
// カズマ様 — leading katakana + kanji
|
||||||
|
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
stats/src/lib/reading-utils.ts
Normal file
73
stats/src/lib/reading-utils.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
function isHiragana(ch: string): boolean {
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
return code >= 0x3040 && code <= 0x309f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKatakana(ch: string): boolean {
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
return code >= 0x30a0 && code <= 0x30ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function katakanaToHiragana(text: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (const ch of text) {
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
if (code >= 0x30a1 && code <= 0x30f6) {
|
||||||
|
result += String.fromCharCode(code - 0x60);
|
||||||
|
} else {
|
||||||
|
result += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct the full word reading from the surface form and the stored
|
||||||
|
* (possibly partial) reading.
|
||||||
|
*
|
||||||
|
* MeCab/Yomitan sometimes stores only the kanji portion's reading. For example,
|
||||||
|
* お前 (surface) with reading まえ — the stored reading covers only 前, missing
|
||||||
|
* the leading お. This function walks through the surface form: hiragana/katakana
|
||||||
|
* characters pass through as-is (converted to hiragana), and the remaining kanji
|
||||||
|
* portion is filled in from the stored reading.
|
||||||
|
*/
|
||||||
|
export function fullReading(headword: string, storedReading: string): string {
|
||||||
|
if (!storedReading || !headword) return storedReading || '';
|
||||||
|
|
||||||
|
const reading = katakanaToHiragana(storedReading);
|
||||||
|
|
||||||
|
const leadingKana: string[] = [];
|
||||||
|
const trailingKana: string[] = [];
|
||||||
|
const chars = [...headword];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
|
||||||
|
leadingKana.push(katakanaToHiragana(chars[i]));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i === chars.length) {
|
||||||
|
return reading;
|
||||||
|
}
|
||||||
|
|
||||||
|
let j = chars.length - 1;
|
||||||
|
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
|
||||||
|
trailingKana.unshift(katakanaToHiragana(chars[j]));
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip matching trailing kana from the stored reading to get the core kanji reading
|
||||||
|
let coreReading = reading;
|
||||||
|
const trailStr = trailingKana.join('');
|
||||||
|
if (trailStr && coreReading.endsWith(trailStr)) {
|
||||||
|
coreReading = coreReading.slice(0, -trailStr.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip matching leading kana from the stored reading if it already includes them
|
||||||
|
const leadStr = leadingKana.join('');
|
||||||
|
if (leadStr && coreReading.startsWith(leadStr)) {
|
||||||
|
return reading;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leadStr + coreReading + trailStr;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user