mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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: '猫' } } },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user