Files
SubMiner/src/anki-integration/known-word-maturity.ts
T
sudacode 38060e0ead fix(anki): exclude learning cards from interval maturity tiers
The interval tier queries matched any card with prop:ivl>=1, but a lapsed
card keeps an interval of at least the lapse minimum, so relearning cards
were caught by the young query first and the learning tier was unreachable
in practice. Add -is:learn to the mature and young queries so the buckets
stay disjoint and match Anki's own card counts; a note with a mature card
alongside a relearning card still resolves to mature.

Bump KNOWN_WORD_MATURITY_RULES_VERSION and fold it into the cache scope key
so caches built under the old rules refetch instead of serving stale tiers.
2026-07-26 01:32:50 -07:00

113 lines
4.0 KiB
TypeScript

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<number>;
young: ReadonlySet<number>;
learning: ReadonlySet<number>;
}
// 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<unknown>,
scopeQueries: string[],
thresholdDays: number,
): Promise<{ mature: Set<number>; young: Set<number>; learning: Set<number> }> {
const sets = {
mature: new Set<number>(),
young: new Set<number>(),
learning: new Set<number>(),
};
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;
}