mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
38060e0ead
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.
113 lines
4.0 KiB
TypeScript
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;
|
|
}
|