fix(stats): persist filter prefs, fix cover MIME types, and dedup alass

- Remember Hide Known/Hide Kana filter state in localStorage across sessions
- Detect PNG and WebP cover art MIME types instead of hardcoding image/jpeg
- Use configured AnkiConnect URL for the browse action
- Deduplicate concurrent in-flight alass retime calls via promise caching
- Prefer request-provided secondary subtitle text over retimed sidecar fallback
- Fix cover image record key types from string to number
This commit is contained in:
2026-06-06 14:48:12 -07:00
parent 99401e5a70
commit 3c3bf3bb18
7 changed files with 275 additions and 37 deletions
@@ -24,6 +24,46 @@ function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
} as VocabularyEntry;
}
function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
const storage = {
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);
},
} as Storage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
try {
return run();
} finally {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
}
}
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
const entry = makeEntry({});
const markup = renderToStaticMarkup(
@@ -84,3 +124,40 @@ test('renders a Hide Kana filter button', () => {
);
assert.match(markup, /Hide Kana/);
});
test('uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
makeEntry({
wordId: 2,
headword: '前に',
word: '前に',
reading: 'まえに',
frequencyRank: 20,
}),
]}
knownWords={new Set()}
/>,
),
);
assert.doesNotMatch(markup, />さらに</);
assert.match(markup, />前に</);
});
test('uses saved Hide Known preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKnown': 'false' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[makeEntry({ headword: '日本語', word: '日本語', frequencyRank: 10 })]}
knownWords={new Set(['日本語'])}
/>,
),
);
assert.match(markup, />Most Common Words Seen</);
assert.match(markup, />日本語</);
});
@@ -10,6 +10,8 @@ interface FrequencyRankTableProps {
}
const PAGE_SIZE = 25;
const HIDE_KNOWN_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKnown';
const HIDE_KANA_ONLY_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKanaOnly';
interface FrequencyRankOptions {
hideKnown: boolean;
@@ -31,6 +33,33 @@ function isKanaOnlyWord(w: VocabularyEntry): boolean {
return isKanaOnlyTokenText(w.headword || w.word);
}
function getPreferenceStorage(): Storage | null {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
function readBooleanPreference(key: string, fallback: boolean): boolean {
try {
const value = getPreferenceStorage()?.getItem(key);
if (value === 'true') return true;
if (value === 'false') return false;
return fallback;
} catch {
return fallback;
}
}
function writeBooleanPreference(key: string, value: boolean): void {
try {
getPreferenceStorage()?.setItem(key, String(value));
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function buildFrequencyRankRows(
words: VocabularyEntry[],
knownWords: Set<string>,
@@ -70,8 +99,12 @@ export function buildFrequencyRankRows(
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [hideKanaOnly, setHideKanaOnly] = useState(false);
const [hideKnown, setHideKnown] = useState(() =>
readBooleanPreference(HIDE_KNOWN_STORAGE_KEY, true),
);
const [hideKanaOnly, setHideKanaOnly] = useState(() =>
readBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, false),
);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
@@ -116,7 +149,9 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
type="button"
aria-pressed={hideKnown}
onClick={() => {
setHideKnown(!hideKnown);
const next = !hideKnown;
setHideKnown(next);
writeBooleanPreference(HIDE_KNOWN_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
@@ -132,7 +167,9 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
type="button"
aria-pressed={hideKanaOnly}
onClick={() => {
setHideKanaOnly(!hideKanaOnly);
const next = !hideKanaOnly;
setHideKanaOnly(next);
writeBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
@@ -150,7 +187,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown && hasKnownData && !hideKanaOnly
? 'All ranked words are already in Anki!'
: hideKnown || hideKanaOnly
: (hideKnown && hasKnownData) || hideKanaOnly
? 'No ranked words match the active filters.'
: 'No words with frequency data.'}
</div>
+2 -2
View File
@@ -88,8 +88,8 @@ export interface StatsCoverImage {
}
export interface StatsCoverImagesData {
anime: Record<string, StatsCoverImage | null>;
media: Record<string, StatsCoverImage | null>;
anime: Record<number, StatsCoverImage | null>;
media: Record<number, StatsCoverImage | null>;
}
export interface KanjiEntry {