diff --git a/changes/anki-maturity-known-word-highlighting.md b/changes/anki-maturity-known-word-highlighting.md index 494f7ade..d8d78342 100644 --- a/changes/anki-maturity-known-word-highlighting.md +++ b/changes/anki-maturity-known-word-highlighting.md @@ -2,3 +2,4 @@ type: added area: overlay - Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. The session help color legend shows the four tier colors while maturity highlighting is on. +- Tiers follow Anki's own card counts: the interval tiers exclude cards in the learning/relearning queue, so a lapsed card shows the learning color instead of young (its interval is reset to at least 1 day, which previously made the learning tier unreachable). A note with a mature card alongside a relearning card still shows mature. diff --git a/docs-site/subtitle-annotations.md b/docs-site/subtitle-annotations.md index 3e2dbb39..3b00f1b5 100644 --- a/docs-site/subtitle-annotations.md +++ b/docs-site/subtitle-annotations.md @@ -49,23 +49,25 @@ Instead of one color for every known word, maturity highlighting tints each know **How it works:** -1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`, `is:new`) - no extra card data is downloaded. -2. Each note gets the tier of its **most mature** card: `mature` (interval ≥ threshold), `young` (in review below the threshold), `learning` (in (re)learning), or `new` (never reviewed). +1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`) - no extra card data is downloaded. +2. Each note gets the tier of its **most mature** card: `mature` (in review, interval ≥ threshold), `young` (in review, interval below the threshold), `learning` (in the learning or relearning queue), or `new` (never studied). The buckets are disjoint, matching Anki's own card counts: a lapsed card in relearning counts as `learning`, not `young`, even though its interval is ≥ 1 day. A note with a mature card plus a relearning card still shows `mature`. 3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting. 4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color. **Key settings:** -| Option | Default | Description | -| -------------------------------------------- | --------- | ---------------------------------------------------------- | -| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) | -| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature | -| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards | -| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for (re)learning cards | -| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards | -| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards | +| Option | Default | Description | +| ------------------------------------------------ | --------- | --------------------------------------------------------------------- | +| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) | +| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature | +| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards | +| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for cards in the learning/relearning queue | +| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards | +| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards | -Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched. +Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched, as does upgrading to a build that revises the tier rules. + +How often the `learning` color appears depends on your deck preset: with no relearning steps configured, a lapsed card returns straight to review and shows `young` instead. While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature). diff --git a/src/anki-integration/known-word-cache-maturity.test.ts b/src/anki-integration/known-word-cache-maturity.test.ts index f095d9cd..98b7ad99 100644 --- a/src/anki-integration/known-word-cache-maturity.test.ts +++ b/src/anki-integration/known-word-cache-maturity.test.ts @@ -98,7 +98,7 @@ test('lifecycle config key is unchanged when maturity is disabled', () => { }; assert.equal( getKnownWordCacheLifecycleConfig(enabled), - '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}', + '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21,"maturityRules":2}', ); const customThreshold: AnkiConnectConfig = { @@ -111,7 +111,19 @@ test('lifecycle config key is unchanged when maturity is disabled', () => { }; assert.equal( getKnownWordCacheLifecycleConfig(customThreshold), - '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30}', + '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30,"maturityRules":2}', + ); +}); + +test('a cache built under the old tier rules is invalidated', () => { + const config: AnkiConnectConfig = { + knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 }, + }; + // v1 rules put lapsed cards in young because the interval queries did not + // exclude is:learn; those persisted tiers must not be served under v2. + assert.notEqual( + getKnownWordCacheLifecycleConfig(config), + '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}', ); }); @@ -120,8 +132,8 @@ test('refresh fetches tier sets and getKnownWordTier classifies notes', async () try { clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '猫' } } }, @@ -151,8 +163,8 @@ test('a note with cards in several tiers counts as its most mature card', async try { clientState.findNotesByQuery.set('deck:"Mining"', [1]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [1]); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]); clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; @@ -169,8 +181,8 @@ test('a word matched by several notes takes the most mature note tier', async () try { clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', []); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', []); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '猫' } } }, @@ -190,8 +202,8 @@ test('tiers are reading-aware for words with several readings', async () => { try { clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } }, @@ -216,8 +228,8 @@ test('reading-only fallback resolves tiers unless opted out', async () => { try { clientState.findNotesByQuery.set('deck:"Mining"', [1]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []); clientState.findNotesByQuery.set('deck:"Mining" is:learn', []); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } }, @@ -265,7 +277,7 @@ test('refresh preserves known-word cache when maturity lookup fails', async () = infoLogs.push(args.map((value) => String(value)).join(' ')); }; clientState.findNotesByQuery.set('deck:"Mining"', [1]); - clientState.failedQueries.add('deck:"Mining" prop:ivl>=21'); + clientState.failedQueries.add('deck:"Mining" prop:ivl>=21 -is:learn'); clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; await manager.refresh(true); @@ -294,8 +306,8 @@ test('tiers persist to v4 state and reload without refetching', async () => { try { Date.now = () => 120_000; clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '猫' } } }, @@ -348,8 +360,8 @@ test('appendFromNoteInfo preserves an existing maturity tier', async () => { try { clientState.findNotesByQuery.set('deck:"Mining"', [7, 8]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [7]); - clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [7]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [8]); clientState.notesInfoResult = [ { noteId: 7, fields: { Word: { value: '猫' } } }, diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index 39cfe811..13dd508d 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -7,6 +7,7 @@ import { AnkiConnectConfig } from '../types/anki'; import type { KnownWordMaturityTier } from '../types/subtitle'; import { createLogger } from '../logger'; import { + KNOWN_WORD_MATURITY_RULES_VERSION, classifyKnownWordNoteTier, fetchKnownWordMaturityTierSets, getKnownWordMaturityEnabled, @@ -76,10 +77,14 @@ export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): str scope: getKnownWordCacheScopeForConfig(config), fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '', }; - // The maturity field is only added while enabled so persisted caches from + // The maturity fields are only added while enabled so persisted caches from // before the feature existed (or with it off) keep their identity. + // maturityRules is the classification-rule version: bump it whenever the tier + // queries change meaning so existing caches refetch instead of serving tiers + // computed under the old rules. if (getKnownWordMaturityEnabled(config)) { payload.maturity = getMatureIntervalThresholdDays(config); + payload.maturityRules = KNOWN_WORD_MATURITY_RULES_VERSION; } return JSON.stringify(payload); } diff --git a/src/anki-integration/known-word-maturity.test.ts b/src/anki-integration/known-word-maturity.test.ts index d6be6314..c4214932 100644 --- a/src/anki-integration/known-word-maturity.test.ts +++ b/src/anki-integration/known-word-maturity.test.ts @@ -46,15 +46,26 @@ test('mature threshold falls back to default for invalid values', () => { test('tier queries append Anki search props to a deck scope query', () => { const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21); - assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21'); - assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21'); + assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21 -is:learn'); + assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn'); + assert.equal(queries.learning, 'deck:"Mining" is:learn'); +}); + +test('interval tiers exclude (re)learning cards so the buckets stay disjoint', () => { + const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21); + // A lapsed card keeps an interval of at least the lapse minInt (>= 1), so + // without the exclusion the young query would claim every relearning card + // and the learning tier could only ever match brand-new cards mid-step. + for (const intervalQuery of [queries.mature, queries.young]) { + assert.ok(intervalQuery.includes('-is:learn')); + } assert.equal(queries.learning, 'deck:"Mining" is:learn'); }); test('tier queries with an empty scope query have no leading space', () => { const queries = buildKnownWordMaturityTierQueries('', 30); - assert.equal(queries.mature, 'prop:ivl>=30'); - assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30'); + assert.equal(queries.mature, 'prop:ivl>=30 -is:learn'); + assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30 -is:learn'); assert.equal(queries.learning, 'is:learn'); }); diff --git a/src/anki-integration/known-word-maturity.ts b/src/anki-integration/known-word-maturity.ts index 13cf3366..12db3c4b 100644 --- a/src/anki-integration/known-word-maturity.ts +++ b/src/anki-integration/known-word-maturity.ts @@ -3,6 +3,10 @@ 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']; @@ -37,14 +41,20 @@ export function getMatureIntervalThresholdDays(config: AnkiConnectConfig): numbe // 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}`, - young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays}`, + mature: `${prefix}prop:ivl>=${thresholdDays} -is:learn`, + young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays} -is:learn`, learning: `${prefix}is:learn`, }; }