mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
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:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user