import type { AnkiConnectConfig } from '../types/anki'; import type { KnownWordMaturityTier } from '../types/subtitle'; export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21; // Version of the tier classification rules; part of the known-word cache // identity so a rule change invalidates caches built under the old rules. export const KNOWN_WORD_MATURITY_RULES_VERSION = 2; // Ascending maturity; index order backs tier comparison. const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature']; export interface KnownWordMaturityTierQueries { mature: string; young: string; learning: string; } export interface KnownWordMaturityTierSets { mature: ReadonlySet; young: ReadonlySet; learning: ReadonlySet; } // Maturity tiers only affect how known-word highlights render, so both flags // must be on before tier data is fetched or served. export function getKnownWordMaturityEnabled(config: AnkiConnectConfig): boolean { return ( config.knownWords?.highlightEnabled === true && config.knownWords?.maturityEnabled === true ); } export function getMatureIntervalThresholdDays(config: AnkiConnectConfig): number { const threshold = config.knownWords?.matureThresholdDays; if (typeof threshold === 'number' && Number.isFinite(threshold) && threshold >= 1) { return Math.floor(threshold); } return DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS; } // Anki search props classify notes server-side: a note matches a tier query // when ANY of its cards matches, which implements most-mature-card-wins for // free once tiers are checked in mature > young > learning order. // // The interval tiers exclude is:learn so the per-card buckets stay disjoint and // match Anki's own card counts, where (re)learning is its own bucket rather // than part of young/mature. Without the exclusion a lapsed card - whose // interval is reset to at least lapse minInt, so >= 1 - is caught by the young // query first and the learning tier becomes unreachable in practice. export function buildKnownWordMaturityTierQueries( scopeQuery: string, thresholdDays: number, ): KnownWordMaturityTierQueries { const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : ''; return { mature: `${prefix}prop:ivl>=${thresholdDays} -is:learn`, young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays} -is:learn`, learning: `${prefix}is:learn`, }; } export async function fetchKnownWordMaturityTierSets( findNotes: (query: string, options?: { maxRetries?: number }) => Promise, scopeQueries: string[], thresholdDays: number, ): Promise<{ mature: Set; young: Set; learning: Set }> { const sets = { mature: new Set(), young: new Set(), learning: new Set(), }; for (const scopeQuery of scopeQueries) { const queries = buildKnownWordMaturityTierQueries(scopeQuery, thresholdDays); for (const tier of ['mature', 'young', 'learning'] as const) { const noteIds = (await findNotes(queries[tier], { maxRetries: 0 })) as number[]; if (!Array.isArray(noteIds)) { continue; } for (const noteId of noteIds) { if (Number.isInteger(noteId) && noteId > 0) { sets[tier].add(noteId); } } } } return sets; } export function classifyKnownWordNoteTier( noteId: number, sets: KnownWordMaturityTierSets, ): KnownWordMaturityTier { if (sets.mature.has(noteId)) return 'mature'; if (sets.young.has(noteId)) return 'young'; if (sets.learning.has(noteId)) return 'learning'; return 'new'; } export function maxKnownWordMaturityTier( a: KnownWordMaturityTier | null | undefined, b: KnownWordMaturityTier | null | undefined, ): KnownWordMaturityTier | null { if (!a) return b ?? null; if (!b) return a; return TIER_ORDER.indexOf(a) >= TIER_ORDER.indexOf(b) ? a : b; } export function sanitizeKnownWordMaturityTier(value: unknown): KnownWordMaturityTier | null { return typeof value === 'string' && TIER_ORDER.includes(value as KnownWordMaturityTier) ? (value as KnownWordMaturityTier) : null; }