From 4677f5624b6384766827ac3a052027704a6bded7 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 1 Oct 2026 22:56:38 -0700 Subject: [PATCH] fix(stats): count mined cards once and keep lexical stats exact after deletes - Card creation now owns mined-card counting; remove the outer single and multi-line mining increments that double counted each card. - Preserve fractional first_seen/last_seen precision when session deletes compare and refresh lexical aggregates. - Derive vocabulary_visible from the stored word row on every write so the new-word charts agree with the filtered vocabulary summary. - Bump the lexical rollup version so existing databases re-derive visibility and repair stale last_seen values once in the background worker. --- changes/stats-mining-and-delete-accuracy.md | 6 + .../__tests__/lexical-removal.test.ts | 40 ++++++- .../immersion-tracker/__tests__/query.test.ts | 8 +- .../immersion-tracker/lexical-rollups.test.ts | 108 +++++++++++++++++- .../immersion-tracker/lexical-rollups.ts | 26 ++++- .../immersion-tracker/query-shared.ts | 16 +-- .../services/immersion-tracker/storage.ts | 29 +++-- src/core/services/mining.test.ts | 35 ------ src/core/services/mining.ts | 8 +- src/main.ts | 8 -- .../runtime/anki-actions-main-deps.test.ts | 13 +-- src/main/runtime/anki-actions-main-deps.ts | 2 - src/main/runtime/anki-actions.test.ts | 6 +- src/main/runtime/anki-actions.ts | 7 +- .../runtime/mining-actions-main-deps.test.ts | 4 - src/main/runtime/mining-actions-main-deps.ts | 4 - src/main/runtime/mining-actions.test.ts | 4 +- src/main/runtime/mining-actions.ts | 3 - 18 files changed, 213 insertions(+), 114 deletions(-) create mode 100644 changes/stats-mining-and-delete-accuracy.md diff --git a/changes/stats-mining-and-delete-accuracy.md b/changes/stats-mining-and-delete-accuracy.md new file mode 100644 index 00000000..9247bbb8 --- /dev/null +++ b/changes/stats-mining-and-delete-accuracy.md @@ -0,0 +1,6 @@ +type: fixed +area: stats + +- Fixed sentence mining (single and multi-line) counting each created card twice in session and lifetime stats. +- Fixed deleting a session leaving word and kanji "last seen" dates pointing at the deleted session; affected rows are repaired once in the background on next launch. +- Fixed re-seen words that the vocabulary filter hides still being counted in the new-words charts, so charts and the vocabulary summary now agree. Existing mismatches are repaired by the same background pass. diff --git a/src/core/services/immersion-tracker/__tests__/lexical-removal.test.ts b/src/core/services/immersion-tracker/__tests__/lexical-removal.test.ts index 50e1ec8c..33de22b2 100644 --- a/src/core/services/immersion-tracker/__tests__/lexical-removal.test.ts +++ b/src/core/services/immersion-tracker/__tests__/lexical-removal.test.ts @@ -30,7 +30,13 @@ function cleanupDbPath(dbPath: string): void { */ function seed( db: DatabaseSync, - lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>, + lines: Array<{ + session: 1 | 2; + wordId: number; + dayOffset: number; + msOffset?: number; + count?: number; + }>, options: { legacyRows?: boolean } = {}, ): void { db.exec(` @@ -65,7 +71,7 @@ function seed( let lineId = 0; for (const line of lines) { lineId += 1; - const seenMs = BASE_MS + line.dayOffset * DAY_MS; + const seenMs = BASE_MS + line.dayOffset * DAY_MS + (line.msOffset ?? 0); insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs); insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`); if (options.legacyRows) { @@ -83,13 +89,13 @@ function seed( FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id ), first_seen = ( - SELECT MIN(sl.CREATED_DATE) / 1000 + SELECT MIN(sl.CREATED_DATE) / 1000.0 FROM imm_word_line_occurrences o JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id WHERE o.word_id = imm_words.id ), last_seen = ( - SELECT MAX(sl.LAST_UPDATE_DATE) / 1000 + SELECT MAX(sl.LAST_UPDATE_DATE) / 1000.0 FROM imm_word_line_occurrences o JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id WHERE o.word_id = imm_words.id @@ -200,6 +206,32 @@ test('deleting the latest session moves last_seen back to the surviving line', ( } }); +for (const legacyRows of [false, true]) { + test(`deleting the latest session keeps fractional seen timestamps exact (legacyRows=${legacyRows})`, () => { + // Stored seen values are fractional seconds; whole-second rounding used to miss + // the removed extreme (or truncate the refreshed one). + const { db, dbPath } = createDb( + [ + { session: 1, wordId: 22, dayOffset: 0, msOffset: 251 }, + { session: 2, wordId: 22, dayOffset: 5, msOffset: 882 }, + ], + { legacyRows }, + ); + + try { + deleteSession(db, 2); + + const word = readWord(db, 22); + assert.equal(word?.frequency, 1); + assert.equal(word?.firstSeen, (BASE_MS + 251) / 1000); + assert.equal(word?.lastSeen, (BASE_MS + 251) / 1000); + } finally { + db.close(); + cleanupDbPath(dbPath); + } + }); +} + test('deleting an interior occurrence leaves the surrounding extremes untouched', () => { // Session 2 carries the middle occurrence; sessions bracket it in time. const { db, dbPath } = createDb([ diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index d7fc64e5..36b9c3fe 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -4567,13 +4567,13 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li assert.ok(sharedWordRow); assert.equal(sharedWordRow.frequency, 1); - assert.equal(sharedWordRow.first_seen, Math.floor(keptTs / 1000)); - assert.equal(sharedWordRow.last_seen, Math.floor(keptTs / 1000)); + assert.equal(sharedWordRow.first_seen, keptTs / 1000); + assert.equal(sharedWordRow.last_seen, keptTs / 1000); assert.equal(deletedOnlyWordRow ?? null, null); assert.ok(sharedKanjiRow); assert.equal(sharedKanjiRow.frequency, 1); - assert.equal(sharedKanjiRow.first_seen, Math.floor(keptTs / 1000)); - assert.equal(sharedKanjiRow.last_seen, Math.floor(keptTs / 1000)); + assert.equal(sharedKanjiRow.first_seen, keptTs / 1000); + assert.equal(sharedKanjiRow.last_seen, keptTs / 1000); assert.equal(deletedOnlyKanjiRow ?? null, null); } finally { db.close(); diff --git a/src/core/services/immersion-tracker/lexical-rollups.test.ts b/src/core/services/immersion-tracker/lexical-rollups.test.ts index 60ce4daf..179544a3 100644 --- a/src/core/services/immersion-tracker/lexical-rollups.test.ts +++ b/src/core/services/immersion-tracker/lexical-rollups.test.ts @@ -16,7 +16,8 @@ import { } from './query-lexical'; import { Database } from './sqlite'; import type { DatabaseSync } from './sqlite'; -import { ensureSchema } from './storage'; +import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage'; +import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures'; function makeDbPath(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-')); @@ -125,6 +126,109 @@ test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rule } }); +test('re-seeing a filtered legacy word keeps charts consistent with the summary', () => { + const db = createDb(); + + try { + const videoId = seedVideo(db, null, 'episode'); + const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 }); + const firstSeen = BASE_MS / 1000; + // Legacy row whose stored merged-token POS fails the persistence filter. + db.prepare( + `INSERT INTO imm_words( + headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency + ) VALUES ('二死', 'にし', 'にし', 'other', '助詞|動詞', '格助詞|自立', '一般|*', ?, ?, 1)`, + ).run(firstSeen, firstSeen); + rebuildLexicalDailyRollups(db); + + // The incoming token passes the filter, but the upsert keeps the stored POS. + executeQueuedWrite( + { + kind: 'subtitleLine', + sessionId, + videoId, + lineIndex: 1, + segmentStartMs: null, + segmentEndMs: null, + text: 'にし', + wordOccurrences: [ + { + headword: '二死', + word: 'にし', + reading: 'にし', + partOfSpeech: 'verb', + pos1: '動詞', + pos2: '自立', + pos3: '*', + frequencyRank: null, + occurrenceCount: 1, + }, + ], + kanjiOccurrences: [], + firstSeen: firstSeen + 60, + lastSeen: firstSeen + 60, + }, + createTrackerPreparedStatements(db), + ); + + const plotted = getVocabularyChartData(db).newWordsTimeline.reduce( + (sum, day) => sum + day.wordCount, + 0, + ); + assert.equal(getVocabularySummary(db, null).uniqueWords, 0); + assert.equal(plotted, 0); + } finally { + db.close(); + } +}); + +test('lexical rollup rebuild repairs last_seen left pointing at deleted history', () => { + const db = createDb(); + + try { + const videoId = seedVideo(db, null, 'episode'); + const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 }); + const lineId = Number( + db + .prepare( + `INSERT INTO imm_subtitle_lines(session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (?, ?, 1, '猫', ?, ?)`, + ) + .run(sessionId, videoId, BASE_MS, BASE_MS).lastInsertRowid, + ); + const seen = (BASE_MS + 251) / 1000; + const stale = seen + 86_400.631; + db.prepare( + `INSERT INTO imm_words(id, headword, word, reading, first_seen, last_seen, frequency) + VALUES (1, '猫', '猫', 'ねこ', ?, ?, 1), (2, '犬', '犬', 'いぬ', ?, ?, 1)`, + ).run(seen, stale, seen - 0.1, seen - 0.1); + db.prepare( + `INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (1, '猫', ?, ?, 1)`, + ).run(seen, stale); + db.prepare( + `INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) + VALUES (?, 1, 1, ?), (?, 2, 1, ?)`, + ).run(lineId, BASE_MS + 251, lineId, BASE_MS + 251); + db.prepare( + `INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms) + VALUES (?, 1, 1, ?)`, + ).run(lineId, BASE_MS + 251); + + rebuildLexicalDailyRollups(db); + + const lastSeen = (table: string): number[] => + ( + db.prepare(`SELECT last_seen AS value FROM ${table} ORDER BY id`).all() as Array<{ + value: number; + }> + ).map((row) => row.value); + assert.deepEqual(lastSeen('imm_words'), [seen, seen - 0.1], 'healthy rows are untouched'); + assert.deepEqual(lastSeen('imm_kanji'), [seen]); + } finally { + db.close(); + } +}); + test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); @@ -268,7 +372,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', () state_value INTEGER NOT NULL ); INSERT INTO imm_rollup_state(state_key, state_value) - VALUES ('lexical_daily_rollups_version', 2); + VALUES ('lexical_daily_rollups_version', 3); `); assert.equal(areLexicalDailyRollupsReady(db), true); diff --git a/src/core/services/immersion-tracker/lexical-rollups.ts b/src/core/services/immersion-tracker/lexical-rollups.ts index ea5480a9..a09f5383 100644 --- a/src/core/services/immersion-tracker/lexical-rollups.ts +++ b/src/core/services/immersion-tracker/lexical-rollups.ts @@ -18,7 +18,9 @@ const LOCAL_EPOCH_DAY_SQL = ` ) - 2440587.5 AS INTEGER) `; -const LEXICAL_DAILY_ROLLUP_VERSION = '2'; +// v3 re-derives visibility for rows a re-seen word forced visible and repairs +// last_seen values left behind by session deletes. +const LEXICAL_DAILY_ROLLUP_VERSION = '3'; const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version'; const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000; @@ -26,6 +28,27 @@ export function localEpochDaySql(value: string): string { return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value); } +// Session deletes once rounded removed timestamps to whole seconds, missing +// fractional last_seen extremes that pointed at deleted history. Healthy rows +// never exceed their newest dated occurrence (seen_ms is stamped at flush, after +// last_seen), so only stale rows match. Rows with undated occurrences are skipped. +function repairStaleLastSeen(db: DatabaseSync): void { + for (const [table, occurrenceTable, col] of [ + ['imm_words', 'imm_word_line_occurrences', 'word_id'], + ['imm_kanji', 'imm_kanji_line_occurrences', 'kanji_id'], + ] as const) { + const newestSeen = `(SELECT MAX(o.seen_ms) FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id) / 1000.0`; + db.exec(` + UPDATE ${table} + SET last_seen = ${newestSeen} + WHERE last_seen > ${newestSeen} + AND NOT EXISTS ( + SELECT 1 FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id AND o.seen_ms IS NULL + ) + `); + } +} + function createWordRollupTriggers(db: DatabaseSync): void { const dayForNew = localEpochDaySql('NEW.first_seen'); const dayForOld = localEpochDaySql('OLD.first_seen'); @@ -186,6 +209,7 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void { lastId = vocabularyRows[vocabularyRows.length - 1]!.id; if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break; } + repairStaleLastSeen(db); db.exec('DELETE FROM imm_lexical_daily_rollups'); db.exec(` INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) diff --git a/src/core/services/immersion-tracker/query-shared.ts b/src/core/services/immersion-tracker/query-shared.ts index 5d09a19b..78ecda16 100644 --- a/src/core/services/immersion-tracker/query-shared.ts +++ b/src/core/services/immersion-tracker/query-shared.ts @@ -298,9 +298,11 @@ export function planLexicalRemovalsForVideos( return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds); } +// first_seen/last_seen are REAL seconds that keep millisecond fractions; never round +// them, or comparisons against stored extremes miss and refreshes drift. function toStoredSeenSeconds(ms: number | null): number | null { if (ms === null || !Number.isFinite(ms)) return null; - return Math.floor(ms / 1000); + return ms / 1000; } /** @@ -405,8 +407,8 @@ function applyRemovalsForEntity( } updateAggregatesStmt.run( nextFrequency, - Math.floor(Number(minSeenMs) / 1000), - Math.floor(Number(maxSeenMs) / 1000), + toStoredSeenSeconds(Number(minSeenMs)), + toStoredSeenSeconds(Number(maxSeenMs)), removal.id, ); continue; @@ -471,8 +473,8 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void { } updateStmt.run( row.frequency, - Math.floor(row.firstSeen / 1000), - Math.floor(row.lastSeen / 1000), + toStoredSeenSeconds(row.firstSeen), + toStoredSeenSeconds(row.lastSeen), row.wordId, ); } @@ -527,8 +529,8 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void { } updateStmt.run( row.frequency, - Math.floor(row.firstSeen / 1000), - Math.floor(row.lastSeen / 1000), + toStoredSeenSeconds(row.firstSeen), + toStoredSeenSeconds(row.lastSeen), row.kanjiId, ); } diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index 533ec88d..13e7c306 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -6,6 +6,7 @@ import { normalizeTitleIdentity } from '../../utils/title-normalization'; import type { DatabaseSync } from './sqlite'; import { nowMs } from './time'; import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups'; +import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility'; import { SCHEMA_VERSION } from './types'; import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types'; import { toDbMs, toDbTimestamp } from './query-shared'; @@ -17,7 +18,8 @@ export interface TrackerPreparedStatements { wordUpsertStmt: ReturnType; kanjiUpsertStmt: ReturnType; subtitleLineInsertStmt: ReturnType; - wordIdSelectStmt: ReturnType; + wordSelectStmt: ReturnType; + wordVisibilityUpdateStmt: ReturnType; kanjiIdSelectStmt: ReturnType; wordLineOccurrenceUpsertStmt: ReturnType; kanjiLineOccurrenceUpsertStmt: ReturnType; @@ -1759,7 +1761,6 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1), pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2), pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3), - vocabulary_visible = 1, first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen), last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen), frequency_rank = CASE @@ -1787,10 +1788,16 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `), - wordIdSelectStmt: db.prepare(` - SELECT id FROM imm_words + wordSelectStmt: db.prepare(` + SELECT id, word, headword, reading, part_of_speech AS partOfSpeech, + pos1, pos2, pos3, frequency_rank AS frequencyRank, + vocabulary_visible AS vocabularyVisible + FROM imm_words WHERE headword = ? AND word = ? AND reading = ? `), + wordVisibilityUpdateStmt: db.prepare(` + UPDATE imm_words SET vocabulary_visible = ? WHERE id = ? + `), kanjiIdSelectStmt: db.prepare(` SELECT id FROM imm_kanji WHERE kanji = ? @@ -1842,14 +1849,18 @@ function incrementWordAggregate( occurrence.frequencyRank ?? null, ); } - const row = stmts.wordIdSelectStmt.get( - occurrence.headword, - occurrence.word, - occurrence.reading, - ) as { id: number } | null; + const row = stmts.wordSelectStmt.get(occurrence.headword, occurrence.word, occurrence.reading) as + | (VocabularyVisibilityRow & { id: number; vocabularyVisible: number }) + | null; if (!row?.id) { throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`); } + // The upsert keeps the first-seen POS metadata, so derive visibility from the + // stored row (as the vocabulary summary does) rather than the incoming token. + const visible = isVocabularyStatsRowVisible(row) ? 1 : 0; + if (row.vocabularyVisible !== visible) { + stmts.wordVisibilityUpdateStmt.run(visible, row.id); + } return row.id; } diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index 5e6ad438..772f1f94 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -276,7 +276,6 @@ test('handleMultiCopyDigit copies backward from the current subtitle after a bac test('handleMineSentenceDigit reports async create failures', async () => { const osd: string[] = []; const logs: Array<{ message: string; err: unknown }> = []; - let cardsMined = 0; handleMineSentenceDigit(2, { subtitleTimingTracker: { @@ -296,9 +295,6 @@ test('handleMineSentenceDigit reports async create failures', async () => { getCurrentSecondarySubText: () => 'sub2', showMpvOsd: (text) => osd.push(text), logError: (message, err) => logs.push({ message, err }), - onCardsMined: (count) => { - cardsMined += count; - }, }); await new Promise((resolve) => setImmediate(resolve)); @@ -307,37 +303,6 @@ test('handleMineSentenceDigit reports async create failures', async () => { assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:'); assert.equal((logs[0]?.err as Error).message, 'mine boom'); assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom'))); - assert.equal(cardsMined, 0); -}); - -test('handleMineSentenceDigit increments successful card count', async () => { - const osd: string[] = []; - let cardsMined = 0; - - handleMineSentenceDigit(2, { - subtitleTimingTracker: { - getRecentBlocks: () => ['one', 'two'], - getCurrentSubtitle: () => null, - findTiming: (text) => - text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 }, - }, - ankiIntegration: { - updateLastAddedFromClipboard: async () => {}, - triggerFieldGroupingForLastAddedCard: async () => {}, - markLastCardAsAudioCard: async () => {}, - createSentenceCard: async () => true, - }, - getCurrentSecondarySubText: () => 'sub2', - showMpvOsd: (text) => osd.push(text), - logError: () => {}, - onCardsMined: (count) => { - cardsMined += count; - }, - }); - - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(cardsMined, 1); }); test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => { diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index 62b199e5..b6aa59e7 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -201,7 +201,6 @@ export function handleMineSentenceDigit( getCurrentSecondarySubText: () => string | undefined; showMpvOsd: (text: string) => void; logError: (message: string, err: unknown) => void; - onCardsMined?: (count: number) => void; }, ): void { if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return; @@ -233,14 +232,9 @@ export function handleMineSentenceDigit( entries, deps.getCurrentSecondarySubText, ); - const cardsToMine = 1; + // Card creation records the mined card (with its note id) itself. deps.ankiIntegration .createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText) - .then((created) => { - if (created) { - deps.onCardsMined?.(cardsToMine); - } - }) .catch((err) => { deps.logError('mineSentenceMultiple failed:', err); deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`); diff --git a/src/main.ts b/src/main.ts index 44035cb3..81262638 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5497,10 +5497,6 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(), showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), mineSentenceCardCore, - recordCardsMined: (count, noteIds) => { - ensureImmersionTrackerStarted(); - appState.immersionTracker?.recordCardsMined(count, noteIds); - }, }); const mineSentenceCardHandler = createMineSentenceCardHandler( buildMineSentenceCardMainDepsHandler(), @@ -5533,10 +5529,6 @@ const buildHandleMineSentenceDigitMainDepsHandler = logError: (message, err) => { logger.error(message, err); }, - onCardsMined: (cards) => { - ensureImmersionTrackerStarted(); - appState.immersionTracker?.recordCardsMined(cards); - }, handleMineSentenceDigitCore, }); const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler(); diff --git a/src/main/runtime/anki-actions-main-deps.test.ts b/src/main/runtime/anki-actions-main-deps.test.ts index d436f022..a75489ad 100644 --- a/src/main/runtime/anki-actions-main-deps.test.ts +++ b/src/main/runtime/anki-actions-main-deps.test.ts @@ -67,7 +67,6 @@ test('anki action main deps builders map callbacks', async () => { getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }), showMpvOsd: (text) => calls.push(`mine:${text}`), mineSentenceCardCore: async () => true, - recordCardsMined: (count) => calls.push(`cards:${count}`), })(); assert.deepEqual(mine.getMpvClient(), { connected: true }); assert.deepEqual(mine.getPrimarySubtitle?.(), { @@ -81,16 +80,6 @@ test('anki action main deps builders map callbacks', async () => { mpvClient: { connected: true }, showMpvOsd: () => {}, }); - mine.recordCardsMined(1); - assert.deepEqual(calls, [ - 'osd:x', - 'update', - 'fg:fg', - 'trigger', - 'audio:a', - 'mark', - 'mine:m', - 'cards:1', - ]); + assert.deepEqual(calls, ['osd:x', 'update', 'fg:fg', 'trigger', 'audio:a', 'mark', 'mine:m']); }); diff --git a/src/main/runtime/anki-actions-main-deps.ts b/src/main/runtime/anki-actions-main-deps.ts index 5bc8c35b..948bc40e 100644 --- a/src/main/runtime/anki-actions-main-deps.ts +++ b/src/main/runtime/anki-actions-main-deps.ts @@ -80,7 +80,6 @@ export function createBuildMineSentenceCardMainDepsHandler(deps: { primarySubtitle?: PrimarySubtitle; showMpvOsd: (text: string) => void; }) => Promise; - recordCardsMined: (count: number, noteIds?: number[]) => void; }) { return () => ({ getAnkiIntegration: () => deps.getAnkiIntegration(), @@ -95,6 +94,5 @@ export function createBuildMineSentenceCardMainDepsHandler(deps: { primarySubtitle?: PrimarySubtitle; showMpvOsd: (text: string) => void; }) => deps.mineSentenceCardCore(options), - recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds), }); } diff --git a/src/main/runtime/anki-actions.test.ts b/src/main/runtime/anki-actions.test.ts index ff40e22d..2be35a88 100644 --- a/src/main/runtime/anki-actions.test.ts +++ b/src/main/runtime/anki-actions.test.ts @@ -63,7 +63,7 @@ test('trigger and mark handlers delegate to core services', async () => { assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']); }); -test('mine sentence handler records mined cards only when core returns true', async () => { +test('mine sentence handler leaves card counting to card creation', async () => { const calls: string[] = []; const integration = {}; const mpvClient = {}; @@ -78,14 +78,13 @@ test('mine sentence handler records mined cards only when core returns true', as options.showMpvOsd('mine'); return created; }, - recordCardsMined: (count) => calls.push(`cards:${count}`), }); created = false; await mineSentenceCard(); created = true; await mineSentenceCard(); - assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']); + assert.deepEqual(calls, ['osd:mine', 'osd:mine']); }); test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => { @@ -99,7 +98,6 @@ test('mine sentence handler forwards the canonical primary subtitle snapshot', a assert.equal(options.primarySubtitle, primarySubtitle); return true; }, - recordCardsMined: () => {}, }); await mineSentenceCard(); diff --git a/src/main/runtime/anki-actions.ts b/src/main/runtime/anki-actions.ts index 2c5c18c2..e77c2e79 100644 --- a/src/main/runtime/anki-actions.ts +++ b/src/main/runtime/anki-actions.ts @@ -83,18 +83,15 @@ export function createMineSentenceCardHandler(deps: { primarySubtitle?: PrimarySubtitle; showMpvOsd: (text: string) => void; }) => Promise; - recordCardsMined: (count: number, noteIds?: number[]) => void; }) { + // Card creation records the mined card (with its note id) itself. return async (): Promise => { const primarySubtitle = deps.getPrimarySubtitle?.(); - const created = await deps.mineSentenceCardCore({ + await deps.mineSentenceCardCore({ ankiIntegration: deps.getAnkiIntegration(), mpvClient: deps.getMpvClient(), ...(primarySubtitle ? { primarySubtitle } : {}), showMpvOsd: deps.showMpvOsd, }); - if (created) { - deps.recordCardsMined(1); - } }; } diff --git a/src/main/runtime/mining-actions-main-deps.test.ts b/src/main/runtime/mining-actions-main-deps.test.ts index 389c3f5e..87680597 100644 --- a/src/main/runtime/mining-actions-main-deps.test.ts +++ b/src/main/runtime/mining-actions-main-deps.test.ts @@ -45,20 +45,17 @@ test('mining action main deps builders map callbacks', () => { getCurrentSecondarySubText: () => 'sub', showMpvOsd: (text) => calls.push(`mine-osd:${text}`), logError: (message) => calls.push(`err:${message}`), - onCardsMined: (count) => calls.push(`cards:${count}`), handleMineSentenceDigitCore: () => calls.push('mine-digit'), })(); assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub'); mineDigit.showMpvOsd('done'); mineDigit.logError('bad', null); - mineDigit.onCardsMined(2); mineDigit.handleMineSentenceDigitCore(2, { subtitleTimingTracker: { track: true }, ankiIntegration: { enabled: true }, getCurrentSecondarySubText: () => 'sub', showMpvOsd: () => {}, logError: () => {}, - onCardsMined: () => {}, }); assert.deepEqual(calls, [ @@ -70,7 +67,6 @@ test('mining action main deps builders map callbacks', () => { 'copy-current', 'mine-osd:done', 'err:bad', - 'cards:2', 'mine-digit', ]); }); diff --git a/src/main/runtime/mining-actions-main-deps.ts b/src/main/runtime/mining-actions-main-deps.ts index e79fdbd8..4281eeba 100644 --- a/src/main/runtime/mining-actions-main-deps.ts +++ b/src/main/runtime/mining-actions-main-deps.ts @@ -57,7 +57,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler< getCurrentSecondarySubText: () => string | undefined; showMpvOsd: (text: string) => void; logError: (message: string, err: unknown) => void; - onCardsMined: (count: number) => void; handleMineSentenceDigitCore: ( count: number, options: { @@ -66,7 +65,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler< getCurrentSecondarySubText: () => string | undefined; showMpvOsd: (text: string) => void; logError: (message: string, err: unknown) => void; - onCardsMined: (count: number) => void; }, ) => void; }) { @@ -76,7 +74,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler< getCurrentSecondarySubText: () => deps.getCurrentSecondarySubText(), showMpvOsd: (text: string) => deps.showMpvOsd(text), logError: (message: string, err: unknown) => deps.logError(message, err), - onCardsMined: (count: number) => deps.onCardsMined(count), handleMineSentenceDigitCore: ( count: number, options: { @@ -85,7 +82,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler< getCurrentSecondarySubText: () => string | undefined; showMpvOsd: (text: string) => void; logError: (message: string, err: unknown) => void; - onCardsMined: (count: number) => void; }, ) => deps.handleMineSentenceDigitCore(count, options), }); diff --git a/src/main/runtime/mining-actions.test.ts b/src/main/runtime/mining-actions.test.ts index 9b388843..0557998b 100644 --- a/src/main/runtime/mining-actions.test.ts +++ b/src/main/runtime/mining-actions.test.ts @@ -53,7 +53,6 @@ test('mine sentence digit handler forwards all dependencies', () => { getCurrentSecondarySubText: () => 'secondary', showMpvOsd: (text) => calls.push(`osd:${text}`), logError: (message) => calls.push(`err:${message}`), - onCardsMined: (count) => calls.push(`cards:${count}`), handleMineSentenceDigitCore: (count, options) => { assert.equal(count, 4); assert.equal(options.subtitleTimingTracker, tracker); @@ -61,10 +60,9 @@ test('mine sentence digit handler forwards all dependencies', () => { assert.equal(options.getCurrentSecondarySubText(), 'secondary'); options.showMpvOsd('mine'); options.logError('boom', new Error('x')); - options.onCardsMined(2); }, }); handleMineSentenceDigit(4); - assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']); + assert.deepEqual(calls, ['osd:mine', 'err:boom']); }); diff --git a/src/main/runtime/mining-actions.ts b/src/main/runtime/mining-actions.ts index 9ee1935f..797fe5ee 100644 --- a/src/main/runtime/mining-actions.ts +++ b/src/main/runtime/mining-actions.ts @@ -48,7 +48,6 @@ export function createHandleMineSentenceDigitHandler< getCurrentSecondarySubText: () => string | undefined; showMpvOsd: (text: string) => void; logError: (message: string, err: unknown) => void; - onCardsMined: (count: number) => void; handleMineSentenceDigitCore: ( count: number, options: { @@ -57,7 +56,6 @@ export function createHandleMineSentenceDigitHandler< getCurrentSecondarySubText: () => string | undefined; showMpvOsd: (text: string) => void; logError: (message: string, err: unknown) => void; - onCardsMined: (count: number) => void; }, ) => void; }) { @@ -68,7 +66,6 @@ export function createHandleMineSentenceDigitHandler< getCurrentSecondarySubText: deps.getCurrentSecondarySubText, showMpvOsd: deps.showMpvOsd, logError: deps.logError, - onCardsMined: deps.onCardsMined, }); }; }