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:
2026-03-16 01:42:49 -07:00
parent a3ed8dcf3d
commit 6634255f43
4 changed files with 313 additions and 42 deletions

View File

@@ -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} />}

View File

@@ -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>

View 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('かずまさま');
});
});

View 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;
}