Anki maturity-based known-word highlighting (#172)

This commit is contained in:
2026-07-27 23:58:02 -07:00
committed by GitHub
parent 08c6807cb1
commit 987b325edb
54 changed files with 3046 additions and 381 deletions
@@ -82,6 +82,73 @@ test('manual selection store persists overrides and matches later episodes in th
});
});
test('manual selection store applies a season-directory override when later episode guesses differ', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const directory = '/Volumes/jellyfin/anime/toaru-kagaku-no-railgun/Season-2';
const selectedKey = buildCharacterDictionarySeriesKey({
mediaPath: `${directory}/A Certain Scientific Railgun S - 10 - Critical.mkv`,
mediaTitle: 'A Certain Scientific Railgun S - 10 - Critical.mkv',
guess: {
title: 'Critical',
season: null,
episode: 10,
source: 'guessit',
},
});
await store.setOverride({
seriesKey: selectedKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
const laterEpisodeKey = buildCharacterDictionarySeriesKey({
mediaPath: `${directory}/A Certain Scientific Railgun S - 16 - Sisters.mkv`,
mediaTitle: 'A Certain Scientific Railgun S - 16 - Sisters.mkv',
guess: {
title: 'Sisters',
season: null,
episode: 16,
source: 'guessit',
},
});
assert.deepEqual(await store.getOverride(laterEpisodeKey), {
seriesKey: selectedKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
});
test('manual selection store replaces older guessed keys for the same season directory', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const directoryKey = 'volumes-jellyfin-anime-toaru-kagaku-no-railgun-season-2';
const firstKey = `${directoryKey}--critical`;
const laterKey = `${directoryKey}--sisters`;
await store.setOverride({
seriesKey: firstKey,
mediaId: 1057,
mediaTitle: 'Ippatsu Kiki Musume',
staleMediaIds: [],
});
await store.setOverride({
seriesKey: laterKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
assert.deepEqual(await store.getOverride(firstKey), {
seriesKey: laterKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
});
test('manual selection store resolves legacy unscoped override keys', async () => {
const userDataPath = makeTempDir();
const overrideDir = path.join(userDataPath, 'character-dictionaries');
@@ -171,6 +238,42 @@ test('manual selection store prefers exact scoped override over legacy fallback'
});
});
test('manual selection store prefers same-directory override over legacy fallback', async () => {
const userDataPath = makeTempDir();
const overrideDir = path.join(userDataPath, 'character-dictionaries');
fs.mkdirSync(overrideDir, { recursive: true });
const directoryScope = 'volumes-jellyfin-anime-toaru-kagaku-no-railgun-season-2';
fs.writeFileSync(
path.join(overrideDir, 'anilist-overrides.json'),
JSON.stringify({
overrides: [
{
seriesKey: 'sisters',
mediaId: 1057,
mediaTitle: 'Legacy fallback',
staleMediaIds: [],
},
{
seriesKey: `${directoryScope}--critical`,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
},
],
}),
'utf8',
);
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
assert.deepEqual(await store.getOverride(`${directoryScope}--sisters`), {
seriesKey: `${directoryScope}--critical`,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
});
test('manual selection store keeps overrides separate for different season directories', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
@@ -107,6 +107,11 @@ function getLegacySeriesKeyCandidates(seriesKey: string): string[] {
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
}
function getDirectoryScope(seriesKey: string): string | null {
const scopedSeparatorIndex = seriesKey.indexOf('--');
return scopedSeparatorIndex < 0 ? null : seriesKey.slice(0, scopedSeparatorIndex);
}
export function buildCharacterDictionarySeriesKey(input: {
mediaPath: string | null;
mediaTitle: string | null;
@@ -135,7 +140,23 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
const candidates = getLegacySeriesKeyCandidates(seriesKey);
const overrides = readOverrides(filePath);
for (const candidate of candidates) {
const exactMatch = overrides.find((entry) => entry.seriesKey === candidates[0]);
if (exactMatch) return exactMatch;
const directoryScope = getDirectoryScope(seriesKey);
if (directoryScope) {
const scopedMatches = overrides.filter(
(entry) => getDirectoryScope(entry.seriesKey) === directoryScope,
);
const selectedMediaIds = new Set(scopedMatches.map((entry) => entry.mediaId));
if (scopedMatches.length > 0 && selectedMediaIds.size === 1) {
const latest = scopedMatches.at(-1)!;
return {
...latest,
staleMediaIds: dedupeNumbers(scopedMatches.flatMap((entry) => entry.staleMediaIds)),
};
}
}
for (const candidate of candidates.slice(1)) {
const match = overrides.find((entry) => entry.seriesKey === candidate);
if (match) return match;
}
@@ -146,8 +167,11 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
if (!normalized) {
throw new Error('Invalid character dictionary manual selection.');
}
const remaining = readOverrides(filePath).filter(
(entry) => entry.seriesKey !== normalized.seriesKey,
const directoryScope = getDirectoryScope(normalized.seriesKey);
const remaining = readOverrides(filePath).filter((entry) =>
directoryScope
? getDirectoryScope(entry.seriesKey) !== directoryScope
: entry.seriesKey !== normalized.seriesKey,
);
writeOverrides(filePath, [...remaining, normalized]);
},
@@ -42,6 +42,12 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
deps.recordLookup(hit);
return hit;
},
...(deps.getKnownWordTier
? {
getKnownWordTier: (text, reading, options) =>
deps.getKnownWordTier!(text, reading, options),
}
: {}),
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
...(deps.getKnownWordsEnabled
? {