mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-30 07:21:32 -07:00
feat(overlay): Anki maturity-based known-word highlighting
Color known-word subtitle highlights by Anki card maturity (new, learning, young, mature) like asbplayer (#171). Notes are classified server-side with Anki search filters (prop:ivl, is:learn) during the known-word cache refresh, so no per-card data is fetched. A word's tier is its most mature matching card/note, with the same reading-aware matching as boolean known-word lookups. - known-word cache v4 state persists per-note tiers; lifecycle key only gains the maturity field while enabled so existing caches survive - ankiConnect.knownWords.maturityEnabled + matureThresholdDays (21) - subtitleStyle.knownWordMaturityColors with catppuccin defaults - word-maturity-<tier> class rides on word-known so hover/selection rules keep applying; falls back to knownWordColor without tier data - runtime toggle subtitle.annotation.knownWords.maturityEnabled
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
Token,
|
||||
FrequencyDictionaryLookup,
|
||||
JlptLevel,
|
||||
KnownWordMaturityTier,
|
||||
PartOfSpeech,
|
||||
} from '../../types';
|
||||
import {
|
||||
@@ -43,6 +44,12 @@ export type KnownWordLookupFn = (
|
||||
options?: { allowReadingOnlyMatch?: boolean },
|
||||
) => boolean;
|
||||
|
||||
export type KnownWordTierLookupFn = (
|
||||
text: string,
|
||||
reading?: string,
|
||||
options?: { allowReadingOnlyMatch?: boolean },
|
||||
) => KnownWordMaturityTier | null;
|
||||
|
||||
export interface TokenizerServiceDeps {
|
||||
getYomitanExt: () => Extension | null;
|
||||
getYomitanSession?: () => Session | null;
|
||||
@@ -53,6 +60,7 @@ export interface TokenizerServiceDeps {
|
||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: KnownWordLookupFn;
|
||||
getKnownWordTier?: KnownWordTierLookupFn;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
@@ -88,6 +96,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: KnownWordLookupFn;
|
||||
getKnownWordTier?: KnownWordTierLookupFn;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
@@ -204,6 +213,11 @@ async function applyAnnotationStage(
|
||||
tokens,
|
||||
{
|
||||
isKnownWord: getKnownWordLookup(deps, options),
|
||||
// Maturity tiers only refine known-word rendering, so they follow the
|
||||
// known-word toggle rather than the N+1 gate.
|
||||
...(options.knownWordsEnabled && deps.getKnownWordTier
|
||||
? { getKnownWordTier: deps.getKnownWordTier }
|
||||
: {}),
|
||||
knownWordMatchMode: deps.getKnownWordMatchMode(),
|
||||
getJlptLevel: deps.getJlptLevel,
|
||||
},
|
||||
@@ -242,6 +256,7 @@ export function createTokenizerDepsRuntime(
|
||||
getYomitanParserInitPromise: options.getYomitanParserInitPromise,
|
||||
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
||||
isKnownWord: options.isKnownWord,
|
||||
getKnownWordTier: options.getKnownWordTier,
|
||||
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
||||
getKnownWordsEnabled: options.getKnownWordsEnabled,
|
||||
getJlptLevel: options.getJlptLevel,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { KnownWordMaturityTier, MergedToken, PartOfSpeech } from '../../../types';
|
||||
import { annotateTokens, AnnotationStageDeps } from './annotation-stage';
|
||||
|
||||
function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
|
||||
return {
|
||||
surface: '猫',
|
||||
reading: 'ネコ',
|
||||
headword: '猫',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<AnnotationStageDeps> = {}): AnnotationStageDeps {
|
||||
return {
|
||||
isKnownWord: () => false,
|
||||
knownWordMatchMode: 'headword',
|
||||
getJlptLevel: () => null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('annotateTokens attaches maturity tier to known tokens', () => {
|
||||
const tokens = [makeToken({ surface: '食べた', headword: '食べる', reading: 'タベタ' })];
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '食べる',
|
||||
getKnownWordTier: (text) => (text === '食べる' ? 'mature' : null),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.knownMaturity, 'mature');
|
||||
});
|
||||
|
||||
test('annotateTokens leaves maturity undefined without a tier lookup dep', () => {
|
||||
const tokens = [makeToken()];
|
||||
const result = annotateTokens(tokens, makeDeps({ isKnownWord: () => true }));
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.knownMaturity, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens leaves maturity undefined when the tier lookup has no data', () => {
|
||||
const tokens = [makeToken()];
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({ isKnownWord: () => true, getKnownWordTier: () => null }),
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.knownMaturity, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens never attaches maturity to unknown tokens', () => {
|
||||
const tokens = [makeToken()];
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({ isKnownWord: () => false, getKnownWordTier: () => 'mature' }),
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.knownMaturity, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens resolves maturity through the kana reading fallback', () => {
|
||||
// Token 大体 with a card mined in kana (だいたい): known status comes from
|
||||
// the reading fallback, so the tier must follow the same path.
|
||||
const tierByText = new Map<string, KnownWordMaturityTier>([['だいたい', 'young']]);
|
||||
const seenTierLookups: Array<{
|
||||
text: string;
|
||||
reading: string | undefined;
|
||||
allowReadingOnlyMatch: boolean | undefined;
|
||||
}> = [];
|
||||
const tokens = [
|
||||
makeToken({ surface: '大体', headword: '大体', reading: 'だいたい', endPos: 2 }),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === 'だいたい',
|
||||
getKnownWordTier: (text, reading, options) => {
|
||||
seenTierLookups.push({
|
||||
text,
|
||||
reading,
|
||||
allowReadingOnlyMatch: options?.allowReadingOnlyMatch,
|
||||
});
|
||||
return tierByText.get(text) ?? null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.knownMaturity, 'young');
|
||||
// The fallback lookup must opt out of reading-only matching, exactly like
|
||||
// the boolean known-status fallback.
|
||||
const fallbackLookup = seenTierLookups.find((lookup) => lookup.text === 'だいたい');
|
||||
assert.equal(fallbackLookup?.allowReadingOnlyMatch, false);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps maturity on POS-excluded known tokens', () => {
|
||||
// Known-word annotations survive the POS noise filter; the tier must too.
|
||||
const tokens = [makeToken({ surface: 'の', headword: 'の', reading: 'ノ', pos1: '助詞' })];
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({ isKnownWord: () => true, getKnownWordTier: () => 'learning' }),
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.knownMaturity, 'learning');
|
||||
});
|
||||
|
||||
test('annotateTokens strips maturity when known-word annotation is disabled', () => {
|
||||
const tokens = [makeToken({ knownMaturity: 'mature' })];
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({ isKnownWord: () => true, getKnownWordTier: () => 'mature' }),
|
||||
{ knownWordsEnabled: false },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.knownMaturity, undefined);
|
||||
});
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
|
||||
resolveAnnotationPos2ExclusionSet,
|
||||
} from '../../../token-pos2-exclusions';
|
||||
import { JlptLevel, MergedToken, NPlusOneMatchMode, PartOfSpeech } from '../../../types';
|
||||
import {
|
||||
JlptLevel,
|
||||
KnownWordMaturityTier,
|
||||
MergedToken,
|
||||
NPlusOneMatchMode,
|
||||
PartOfSpeech,
|
||||
} from '../../../types';
|
||||
import { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter';
|
||||
import {
|
||||
shouldExcludeTokenFromSubtitleAnnotations as sharedShouldExcludeTokenFromSubtitleAnnotations,
|
||||
@@ -36,6 +42,11 @@ export interface AnnotationStageDeps {
|
||||
reading?: string,
|
||||
options?: { allowReadingOnlyMatch?: boolean },
|
||||
) => boolean;
|
||||
getKnownWordTier?: (
|
||||
text: string,
|
||||
reading?: string,
|
||||
options?: { allowReadingOnlyMatch?: boolean },
|
||||
) => KnownWordMaturityTier | null;
|
||||
knownWordMatchMode: NPlusOneMatchMode;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
}
|
||||
@@ -616,6 +627,28 @@ function computeTokenKnownStatus(
|
||||
);
|
||||
}
|
||||
|
||||
// Maturity tier for a token already confirmed known, following the same
|
||||
// primary + kana-fallback lookup sequence as computeTokenKnownStatus so the
|
||||
// tier always describes a note the boolean match could have come from.
|
||||
function computeTokenKnownMaturity(
|
||||
token: MergedToken,
|
||||
getKnownWordTier: NonNullable<AnnotationStageDeps['getKnownWordTier']>,
|
||||
knownWordMatchMode: NPlusOneMatchMode,
|
||||
): KnownWordMaturityTier | undefined {
|
||||
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
|
||||
const matchReading = resolveKnownWordReadingForMatch(token, knownWordMatchMode);
|
||||
const primaryTier = matchText ? getKnownWordTier(matchText, matchReading) : null;
|
||||
if (primaryTier) {
|
||||
return primaryTier;
|
||||
}
|
||||
|
||||
const fallbackReading = resolveCompleteTokenReading(token);
|
||||
if (!fallbackReading || fallbackReading === matchText.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
return getKnownWordTier(fallbackReading, undefined, { allowReadingOnlyMatch: false }) ?? undefined;
|
||||
}
|
||||
|
||||
function filterTokenFrequencyRank(
|
||||
token: MergedToken,
|
||||
pos1Exclusions: ReadonlySet<string>,
|
||||
@@ -677,6 +710,11 @@ export function annotateTokens(
|
||||
: false;
|
||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||
|
||||
const knownMaturity =
|
||||
knownWordsEnabled && isKnownForMatching && deps.getKnownWordTier
|
||||
? computeTokenKnownMaturity(token, deps.getKnownWordTier, deps.knownWordMatchMode)
|
||||
: undefined;
|
||||
|
||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||
|
||||
// A confirmed character-name match must survive the POS noise filter:
|
||||
@@ -696,6 +734,7 @@ export function annotateTokens(
|
||||
return {
|
||||
...strippedToken,
|
||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||
knownMaturity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -712,6 +751,7 @@ export function annotateTokens(
|
||||
return {
|
||||
...token,
|
||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||
knownMaturity,
|
||||
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
|
||||
frequencyRank,
|
||||
jlptLevel,
|
||||
|
||||
Reference in New Issue
Block a user