mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -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 { PosBadge } from './pos-helpers';
|
||||
import { fullReading } from '../../lib/reading-utils';
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
|
||||
interface FrequencyRankTableProps {
|
||||
@@ -13,11 +14,12 @@ const PAGE_SIZE = 25;
|
||||
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [hideKnown, setHideKnown] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const hasKnownData = knownWords.size > 0;
|
||||
|
||||
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(() => {
|
||||
@@ -25,7 +27,28 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
if (hideKnown && hasKnownData) {
|
||||
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]);
|
||||
|
||||
if (words.every((w) => w.frequencyRank == null)) {
|
||||
@@ -44,10 +67,15 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">
|
||||
<div className="flex items-center justify-between">
|
||||
<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'}
|
||||
</h3>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasKnownData && (
|
||||
<button
|
||||
@@ -67,13 +95,13 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{ranked.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{collapsed ? null : ranked.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto mt-3">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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}
|
||||
</td>
|
||||
<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 className="py-1.5 pr-3">
|
||||
{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 { apiClient } from '../../lib/api-client';
|
||||
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { fullReading } from '../../lib/reading-utils';
|
||||
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
||||
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 {
|
||||
wordId: number | null;
|
||||
onClose: () => void;
|
||||
onSelectWord?: (wordId: 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 {
|
||||
@@ -22,7 +44,7 @@ function formatSegment(ms: number | null): string {
|
||||
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 [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
|
||||
const [occLoading, setOccLoading] = useState(false);
|
||||
@@ -30,11 +52,23 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
const [occError, setOccError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [occLoaded, setOccLoaded] = useState(false);
|
||||
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
|
||||
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;
|
||||
|
||||
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;
|
||||
if (append) {
|
||||
setOccLoadingMore(true);
|
||||
@@ -45,11 +79,11 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
try {
|
||||
const rows = await apiClient.getWordOccurrences(
|
||||
detail.headword, detail.word, detail.reading,
|
||||
OCCURRENCES_PAGE_SIZE, offset,
|
||||
limit, offset,
|
||||
);
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccurrences(prev => append ? [...prev, ...rows] : rows);
|
||||
setHasMore(rows.length === OCCURRENCES_PAGE_SIZE);
|
||||
setHasMore(rows.length === limit);
|
||||
} catch (err) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccError(err instanceof Error ? err.message : String(err));
|
||||
@@ -67,12 +101,44 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
|
||||
const handleShowOccurrences = () => {
|
||||
if (!data) return;
|
||||
void loadOccurrences(data.detail, 0, false);
|
||||
void loadOccurrences(data.detail, 0, INITIAL_PAGE_SIZE, false);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
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 (
|
||||
@@ -93,7 +159,7 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
{data && (
|
||||
<>
|
||||
<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">
|
||||
{data.detail.partOfSpeech && <PosBadge pos={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 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
|
||||
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"
|
||||
@@ -117,6 +197,7 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{data && (
|
||||
@@ -190,7 +271,7 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>}
|
||||
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
|
||||
{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 && (
|
||||
<div className="space-y-3">
|
||||
@@ -212,23 +293,56 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
{formatNumber(occ.occurrenceCount)} in line
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-ctp-overlay1">
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId}
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
|
||||
<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>
|
||||
{(() => {
|
||||
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">
|
||||
{occ.text}
|
||||
{highlightWord(occ.text, [data!.detail.headword, data!.detail.word])}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{occLoaded && !occLoading && !occError && hasMore && (
|
||||
<div className="border-t border-ctp-surface1 px-4 py-4">
|
||||
{hasMore && (
|
||||
<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"
|
||||
@@ -237,8 +351,13 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
>
|
||||
{occLoadingMore ? 'Loading more...' : 'Load more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</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