Compare commits

..

4 Commits

Author SHA1 Message Date
sudacode d5f887a834 fix(stats): address CodeRabbit review on delete and vocabulary paths
- Plan lexical removals inside each delete transaction. The plan drives a
  subtraction now rather than a recompute, so a snapshot taken before
  BEGIN IMMEDIATE could subtract totals that no longer match the rows
  removed, and only drift reaching <= 0 self-heals.
- Await the active video's pending anime metadata before deleteAnime's
  guard, so a delete issued while the title association is still being
  parsed can't slip past and let the late update recreate the anime row.
- Order vocabulary pages by (frequency, id). The oversample loop re-runs
  with a growing OFFSET, and tied frequencies gave no stable order, so
  rows could be skipped or repeated across pages.
- Clear the delete error when a retry starts, so cancelling leaves no
  stale failure on screen.
- Seed occurrences, kanji, rollups and cover art in the deleteAnime test;
  the empty-table assertions were passing against empty tables.
- Rename both keyframes to kebab-case for stylelint keyframes-name-pattern.
2026-07-28 02:50:36 -07:00
sudacode 77b7ed1fa1 test(launcher): align sync fixture schema with seen_ms columns
The launcher sync fixture duplicates the production immersion schema and
a guard test asserts the two stay identical. Adding seen_ms to the word
and kanji occurrence tables left the fixture behind, so every sync merge
test failed on the missing column.
2026-07-28 02:28:32 -07:00
sudacode af826837ab feat(stats): add library entry deletion and app-wide delete progress
- Add "Delete Entry" to the anime detail view: removes every episode, session, subtitle line, rollup and cover art for a title plus the vocab counts derived from them, then drops it from the Library grid; refused while that title is currently playing.
- Show delete progress (session, session group, episode, entry) app-wide via a top progress bar + status toast that persist across tab switches, detail views, and the stats overlay window instead of going blank off-tab.
- Fix delete and Vocabulary-tab performance: store seen_ms on word/kanji occurrences so deletes subtract only removed rows via a covering index instead of rescanning each word's full history, and paginate vocab rows before aggregating anime counts.
- Migrate existing databases in place on first launch after upgrade.
2026-07-28 02:17:07 -07:00
sudacode 987b325edb Anki maturity-based known-word highlighting (#172) 2026-07-27 23:58:02 -07:00
35 changed files with 1661 additions and 140 deletions
+5
View File
@@ -0,0 +1,5 @@
type: added
area: stats
- Added a "Delete Entry" action to the stats Library detail view. It removes the whole title in one step — every episode, session, subtitle line, rollup, cover art and vocabulary counts derived from them — and the title disappears from the Library grid. Previously only individual episodes and sessions could be deleted, so a mistakenly created entry had to be cleared episode by episode and still lingered in the library.
- Deletion progress is now shown app-wide instead of only on Overview and Sessions. Every delete (session, session group, episode, and library entry) drives a sweeping bar at the top of the window plus a bottom-right status toast, and both stay visible across tab switches, detail views, and the in-app stats overlay window. The per-tab indicators previously went blank the moment you left the tab that started the delete.
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: stats
- Made deleting stats data much faster and stopped it stalling playback. Every delete used to recompute each affected word's totals by re-reading that word's whole occurrence history across the library, which meant one random read into the largest table per occurrence. On a 960-session library that cost 3.8s to delete a single session and over a minute to clear a 12-episode title, and because the stats server runs in the app process when the app owns it, that time was spent blocking mpv.
- Word and kanji occurrences now store the subtitle line's timestamp alongside the count, so those totals are answered from a covering index instead of the subtitle-line table, and a delete only subtracts what it actually removed rather than recomputing untouched entries. Measured on the same 960-session library: deleting one session 3832ms to 190ms, a ten-session day group 6453ms to 433ms, one episode 5437ms to 263ms, and a whole 12-episode title 60556ms to 621ms.
- Opening the Vocabulary tab no longer stalls either. It was computing "seen in N titles" for every word in the library before ordering by frequency and keeping the top 100, so the whole occurrence history was walked to produce one page. It now picks the page first and counts only those rows: 2136ms to 62ms on the same library.
- The first launch after upgrading migrates the existing stats database in place (about 3s for a 288k-line library) and it grows roughly 20% from the added index. Vocabulary dates recorded before the upgrade keep working while the migration runs.
+2
View File
@@ -57,6 +57,8 @@ Jellyfin stream URLs are normalized to stable item links before stats titles are
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
![Stats Library](/screenshots/stats-library.png)
#### Trends
+4 -2
View File
@@ -185,6 +185,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
line_id INTEGER NOT NULL,
word_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, word_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
@@ -193,6 +194,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
line_id INTEGER NOT NULL,
kanji_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, kanji_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
@@ -313,8 +315,8 @@ export const IMMERSION_DB_FIXTURE_DDL = `
CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);
CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);
CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);
CREATE INDEX idx_word_line_occurrences_word ON imm_word_line_occurrences(word_id, line_id);
CREATE INDEX idx_kanji_line_occurrences_kanji ON imm_kanji_line_occurrences(kanji_id, line_id);
CREATE INDEX idx_word_line_occurrences_word_seen ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id);
CREATE INDEX idx_kanji_line_occurrences_kanji_seen ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id);
CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);
CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
+6
View File
@@ -224,13 +224,19 @@ function readPersistedCacheScope(cachePath: string): string | null {
}
}
const ANKI_REQUEST_TIMEOUT_MS = 30_000;
function createAnkiClient(url: string) {
const request = async (action: string, params: unknown): Promise<unknown> => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, version: 6, params }),
signal: AbortSignal.timeout(ANKI_REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`AnkiConnect ${action}: HTTP ${response.status} ${response.statusText}`);
}
const payload = (await response.json()) as { result: unknown; error: string | null };
if (payload.error) {
throw new Error(`AnkiConnect ${action}: ${payload.error}`);
+2 -1
View File
@@ -152,5 +152,6 @@ export async function createYomitanRuntimeStateWithSearch(
}
}
return createYomitanRuntimeState(userDataPath, resolvedExtensionPath ?? undefined);
// No usable manifest at the resolved path, so let the loader search on its own.
return createYomitanRuntimeState(userDataPath);
}
@@ -69,6 +69,18 @@ function createMaturityHarness(config: AnkiConnectConfig): {
};
}
// The four queries a maturity refresh issues, in one place so a query-string
// change lands in a single spot.
function setTierQueries(
clientState: { findNotesByQuery: Map<string, number[]> },
tiers: { all: number[]; mature: number[]; young: number[]; learning: number[] },
): void {
clientState.findNotesByQuery.set('deck:"Mining"', tiers.all);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', tiers.mature);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', tiers.young);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', tiers.learning);
}
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
return {
deck: 'Mining',
@@ -131,10 +143,7 @@ test('refresh fetches tier sets and getKnownWordTier classifies notes', async ()
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]);
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]);
setTierQueries(clientState, { all: [1, 2, 3, 4], mature: [1], young: [2], learning: [3] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
{ noteId: 2, fields: { Word: { value: '犬' } } },
@@ -162,10 +171,7 @@ test('a note with cards in several tiers counts as its most mature card', async
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [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]);
setTierQueries(clientState, { all: [1], mature: [1], young: [1], learning: [1] });
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
await manager.refresh(true);
@@ -180,10 +186,7 @@ test('a word matched by several notes takes the most mature note tier', async ()
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 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]);
setTierQueries(clientState, { all: [1, 2], mature: [], young: [2], learning: [1] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
{ noteId: 2, fields: { Word: { value: '猫' } } },
@@ -201,10 +204,7 @@ test('tiers are reading-aware for words with several readings', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 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', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
@@ -227,10 +227,7 @@ test('reading-only fallback resolves tiers unless opted out', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [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', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', []);
setTierQueries(clientState, { all: [1], mature: [1], young: [], learning: [] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
];
@@ -305,10 +302,7 @@ 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 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
{ noteId: 2, fields: { Word: { value: '犬' } } },
@@ -359,10 +353,7 @@ test('appendFromNoteInfo preserves an existing maturity tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [7, 8]);
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]);
setTierQueries(clientState, { all: [7, 8], mature: [7], young: [], learning: [8] });
clientState.notesInfoResult = [
{ noteId: 7, fields: { Word: { value: '猫' } } },
{ noteId: 8, fields: { Word: { value: '犬' } } },
@@ -389,10 +380,7 @@ test('getKnownWordMatchNoteIds reports the notes behind a tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3]);
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]);
setTierQueries(clientState, { all: [1, 2, 3], mature: [1], young: [2], learning: [3] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
@@ -2873,6 +2873,39 @@ Aligned English subtitle
assert.equal(deleteCalls, 0);
});
it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => {
let deletedAnimeId: number | null = null;
const app = createStatsApp(
createMockTracker({
deleteAnime: async (animeId: number) => {
deletedAnimeId = animeId;
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7', { method: 'DELETE' });
assert.equal(res.status, 200);
assert.equal(deletedAnimeId, 7);
assert.deepEqual(await res.json(), { ok: true });
});
it('DELETE /api/stats/anime/:animeId rejects non-positive anime ids', async () => {
let deleteCalls = 0;
const app = createStatsApp(
createMockTracker({
deleteAnime: async () => {
deleteCalls += 1;
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/0', { method: 'DELETE' });
assert.equal(res.status, 400);
assert.equal(deleteCalls, 0);
});
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
@@ -1466,6 +1466,190 @@ test('deleteVideo ignores the currently active video and keeps new writes flusha
}
});
/**
* Attach the derived rows a real session would produce to every recorded line
* of `animeId`: word/kanji entries and their occurrences, monthly rollups, and
* cover art backed by a shared blob.
*/
function seedDerivedAnimeData(db: DatabaseSync, animeId: number): void {
const lines = db
.prepare(
'SELECT line_id AS lineId, CREATED_DATE AS seenMs FROM imm_subtitle_lines WHERE anime_id = ?',
)
.all(animeId) as Array<{ lineId: number; seenMs: number }>;
assert.ok(lines.length > 0, 'expected recorded subtitle lines to decorate');
db.prepare(
`INSERT INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
VALUES (9001, '天気', '天気', 'てんき', 'noun', '名詞', 0, 0, 0)`,
).run();
db.prepare(
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (9101, '気', 0, 0, 0)`,
).run();
const insertWordOccurrence = db.prepare(
'INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) VALUES (?, 9001, 1, ?)',
);
const insertKanjiOccurrence = db.prepare(
'INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms) VALUES (?, 9101, 1, ?)',
);
for (const line of lines) {
insertWordOccurrence.run(line.lineId, line.seenMs);
insertKanjiOccurrence.run(line.lineId, line.seenMs);
}
db.prepare(
`UPDATE imm_words SET frequency = ?, first_seen = ?, last_seen = ? WHERE id = 9001`,
).run(lines.length, 0, 0);
const videoIds = (
db
.prepare('SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ videoId: number }>
).map((row) => row.videoId);
const insertMonthlyRollup = db.prepare(
`INSERT INTO imm_monthly_rollups(rollup_month, video_id, total_sessions, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (202401, ?, 1, '0', '0')`,
);
const insertArt = db.prepare(
`INSERT INTO imm_media_art(video_id, anilist_id, cover_url, cover_blob, cover_blob_hash, fetched_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, 4242, 'https://example.test/cover.jpg', NULL, 'deadbeef', '0', '0', '0')`,
);
db.prepare(
`INSERT INTO imm_cover_art_blobs(blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('deadbeef', X'FFD8FFD9', '0', '0')`,
).run();
for (const videoId of videoIds) {
insertMonthlyRollup.run(videoId);
insertArt.run(videoId);
}
}
test('deleteAnime removes every episode, session and library row for the title', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
for (const episode of ['S02E05', 'S02E06']) {
tracker = new Ctor({ dbPath });
tracker.handleMediaChange(`/tmp/Little Witch Academia ${episode}.mkv`, `Episode ${episode}`);
await waitForPendingAnimeMetadata(tracker);
tracker.recordSubtitleLine('今日は晴れです', 0, 1.2);
tracker.recordCardsMined(1);
tracker.destroy();
tracker = null;
}
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
const animeId = (
privateApi.db.prepare('SELECT anime_id FROM imm_anime LIMIT 1').get() as {
anime_id: number;
} | null
)?.anime_id;
assert.ok(animeId);
// The tokenizer does not run in this harness, so attach vocabulary, kanji,
// rollups and cover art to the recorded lines by hand. Without them the
// "everything is gone" assertions below would pass against empty tables.
seedDerivedAnimeData(privateApi.db, animeId);
const countOf = (sql: string): number =>
(privateApi.db.prepare(sql).get() as { total: number }).total;
for (const table of [
'imm_words',
'imm_kanji',
'imm_word_line_occurrences',
'imm_kanji_line_occurrences',
'imm_daily_rollups',
'imm_monthly_rollups',
'imm_media_art',
'imm_cover_art_blobs',
]) {
assert.ok(
countOf(`SELECT COUNT(*) AS total FROM ${table}`) > 0,
`precondition: ${table} should hold rows before the delete`,
);
}
const libraryBefore = await tracker.getAnimeLibrary();
assert.equal(libraryBefore.length, 1);
assert.equal(libraryBefore[0]?.episodeCount, 2);
await tracker.deleteAnime(animeId);
const libraryAfter = await tracker.getAnimeLibrary();
assert.equal(libraryAfter.length, 0);
for (const table of [
'imm_anime',
'imm_lifetime_anime',
'imm_videos',
'imm_sessions',
'imm_subtitle_lines',
'imm_daily_rollups',
'imm_monthly_rollups',
'imm_lifetime_media',
'imm_words',
'imm_kanji',
'imm_word_line_occurrences',
'imm_kanji_line_occurrences',
'imm_media_art',
'imm_cover_art_blobs',
]) {
assert.equal(
countOf(`SELECT COUNT(*) AS total FROM ${table}`),
0,
`${table} should be empty after deleting the only title`,
);
}
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('deleteAnime ignores the title of the currently active session', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5');
await waitForPendingAnimeMetadata(tracker);
const privateApi = tracker as unknown as {
db: DatabaseSync;
sessionState: { sessionId: number; videoId: number } | null;
};
const videoId = privateApi.sessionState?.videoId;
assert.ok(videoId);
const animeId = (
privateApi.db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as {
anime_id: number | null;
} | null
)?.anime_id;
assert.ok(animeId);
await tracker.deleteAnime(animeId);
const animeCountRow = privateApi.db
.prepare('SELECT COUNT(*) AS total FROM imm_anime WHERE anime_id = ?')
.get(animeId) as { total: number };
const videoCountRow = privateApi.db
.prepare('SELECT COUNT(*) AS total FROM imm_videos WHERE video_id = ?')
.get(videoId) as { total: number };
assert.equal(animeCountRow.total, 1);
assert.equal(videoCountRow.total, 1);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('handleMediaChange links parsed anime metadata on the active video row', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -83,6 +83,7 @@ import {
} from './immersion-tracker/query-library';
import {
cleanupVocabularyStats,
deleteAnime as deleteAnimeQuery,
deleteSession as deleteSessionQuery,
deleteSessions as deleteSessionsQuery,
deleteVideo as deleteVideoQuery,
@@ -733,6 +734,28 @@ export class ImmersionTrackerService {
deleteVideoQuery(this.db, videoId);
}
async deleteAnime(animeId: number): Promise<void> {
// The active video's anime link is assigned asynchronously after the title
// is parsed, so a guard reading imm_videos too early sees a null and lets
// the delete through — then the late update recreates the anime row.
const pendingVideoId = this.sessionState?.videoId;
if (pendingVideoId !== undefined) {
await this.pendingAnimeMetadataUpdates.get(pendingVideoId);
}
const activeVideoId = this.sessionState?.videoId;
if (activeVideoId !== undefined) {
const activeAnime = this.db
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
.get(activeVideoId) as { anime_id: number | null } | null;
if (activeAnime?.anime_id === animeId) {
this.logger.warn(`Ignoring delete request for active immersion anime ${animeId}`);
return;
}
}
deleteAnimeQuery(this.db, animeId);
}
async reassignAnimeAnilist(
animeId: number,
info: {
@@ -0,0 +1,331 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
import { ensureSchema } from '../storage.js';
import { deleteSession, deleteSessions, deleteVideo } from '../query-maintenance.js';
const DAY_MS = 86_400_000;
const BASE_MS = 1_700_000_000_000;
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-removal-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) return;
fs.rmSync(dir, { recursive: true, force: true });
}
/**
* Seed two episodes of one anime, each with one ended session.
*
* `lines` places a word occurrence on a specific day so tests can control which
* session holds a word's first/last occurrence.
*/
function seed(
db: DatabaseSync,
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
options: { legacyRows?: boolean } = {},
): void {
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'show', 'Show', ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'v1', 1, 'Ep 1', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS}),
(2, 'v2', 1, 'Ep 2', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 's1', 1, '${BASE_MS}', '${BASE_MS + 1000}', 2, ${BASE_MS}, ${BASE_MS}),
(2, 's2', 2, '${BASE_MS + DAY_MS}', '${BASE_MS + DAY_MS + 1000}', 2, ${BASE_MS}, ${BASE_MS});
`);
const insertLine = db.prepare(
`INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, 1, ?, ?, ?, ?)`,
);
const insertWord = db.prepare(
`INSERT OR IGNORE INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
VALUES (?, ?, ?, '', 'noun', '名詞', 0, 0, 0)`,
);
// `legacyRows` reproduces databases written before the seen_ms column existed,
// where the timestamp has to be read back off the subtitle line.
const insertOccurrence = options.legacyRows
? db.prepare(
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count) VALUES (?, ?, ?)`,
)
: db.prepare(
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) VALUES (?, ?, ?, ?)`,
);
let lineId = 0;
for (const line of lines) {
lineId += 1;
const seenMs = BASE_MS + line.dayOffset * DAY_MS;
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
insertWord.run(line.wordId, `${line.wordId}`, `${line.wordId}`);
if (options.legacyRows) {
insertOccurrence.run(lineId, line.wordId, line.count ?? 1);
} else {
insertOccurrence.run(lineId, line.wordId, line.count ?? 1, seenMs);
}
}
// Match what the tracker maintains: aggregates derived from the occurrences.
db.exec(`
UPDATE imm_words SET
frequency = (
SELECT COALESCE(SUM(o.occurrence_count), 0)
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
),
first_seen = (
SELECT MIN(sl.CREATED_DATE) / 1000
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
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
)
`);
}
function createDb(
lines: Parameters<typeof seed>[1],
options: Parameters<typeof seed>[2] = {},
): { db: DatabaseSync; dbPath: string } {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
seed(db, lines, options);
return { db, dbPath };
}
function readWord(
db: DatabaseSync,
wordId: number,
): { frequency: number; firstSeen: number; lastSeen: number } | null {
return (
(db
.prepare(
'SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM imm_words WHERE id = ?',
)
.get(wordId) as { frequency: number; firstSeen: number; lastSeen: number } | null) ?? null
);
}
test('deleting a session subtracts only the occurrences it removed', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 10, dayOffset: 0, count: 3 },
{ session: 2, wordId: 10, dayOffset: 1, count: 4 },
]);
try {
assert.equal(readWord(db, 10)?.frequency, 7);
deleteSession(db, 1);
assert.equal(readWord(db, 10)?.frequency, 4, 'only session 1 occurrences are subtracted');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting the session that held a word first drops the word entirely', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 11, dayOffset: 0 },
{ session: 2, wordId: 12, dayOffset: 1 },
]);
try {
deleteSession(db, 1);
assert.equal(readWord(db, 11), null, 'word seen only in the deleted session is removed');
assert.ok(readWord(db, 12), 'word seen elsewhere survives');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting the earliest session moves first_seen forward to the surviving line', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 13, dayOffset: 0 },
{ session: 2, wordId: 13, dayOffset: 5 },
]);
try {
assert.equal(readWord(db, 13)?.firstSeen, Math.floor(BASE_MS / 1000));
deleteSession(db, 1);
const word = readWord(db, 13);
assert.equal(word?.frequency, 1);
assert.equal(
word?.firstSeen,
Math.floor((BASE_MS + 5 * DAY_MS) / 1000),
'first_seen advances to the remaining occurrence',
);
assert.equal(word?.lastSeen, Math.floor((BASE_MS + 5 * DAY_MS) / 1000));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting the latest session moves last_seen back to the surviving line', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 14, dayOffset: 0 },
{ session: 2, wordId: 14, dayOffset: 5 },
]);
try {
deleteSession(db, 2);
const word = readWord(db, 14);
assert.equal(word?.frequency, 1);
assert.equal(word?.lastSeen, Math.floor(BASE_MS / 1000), 'last_seen falls back to session 1');
assert.equal(word?.firstSeen, Math.floor(BASE_MS / 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([
{ session: 1, wordId: 15, dayOffset: 0 },
{ session: 2, wordId: 15, dayOffset: 3 },
{ session: 1, wordId: 15, dayOffset: 9 },
]);
try {
deleteSessions(db, [2]);
const word = readWord(db, 15);
assert.equal(word?.frequency, 2);
assert.equal(word?.firstSeen, Math.floor(BASE_MS / 1000));
assert.equal(word?.lastSeen, Math.floor((BASE_MS + 9 * DAY_MS) / 1000));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a stored frequency that has drifted low is repaired instead of dropping a live word', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 16, dayOffset: 0, count: 5 },
{ session: 2, wordId: 16, dayOffset: 4, count: 5 },
]);
try {
// Simulate drift: the stored total is lower than the occurrences justify, so
// naive subtraction would take the word to zero while rows still reference it.
db.prepare('UPDATE imm_words SET frequency = 5 WHERE id = ?').run(16);
deleteSession(db, 1);
const word = readWord(db, 16);
assert.ok(word, 'word with surviving occurrences is not deleted');
assert.equal(word?.frequency, 5, 'frequency is recomputed from the surviving occurrences');
assert.equal(word?.firstSeen, Math.floor((BASE_MS + 4 * DAY_MS) / 1000));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting a video subtracts every occurrence carried by its lines', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 17, dayOffset: 0, count: 2 },
{ session: 1, wordId: 17, dayOffset: 1, count: 3 },
{ session: 2, wordId: 17, dayOffset: 2, count: 4 },
{ session: 2, wordId: 18, dayOffset: 2, count: 1 },
]);
try {
deleteVideo(db, 1);
assert.equal(readWord(db, 17)?.frequency, 4, 'both lines from video 1 are subtracted');
assert.equal(readWord(db, 18)?.frequency, 1, 'untouched video keeps its counts');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('occurrence rows written before the seen_ms column still resolve their dates', () => {
const { db, dbPath } = createDb(
[
{ session: 1, wordId: 20, dayOffset: 0 },
{ session: 2, wordId: 20, dayOffset: 6 },
],
{ legacyRows: true },
);
try {
assert.equal(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_word_line_occurrences WHERE seen_ms IS NULL')
.get() as { total: number }
).total,
2,
'precondition: rows carry no denormalised timestamp',
);
deleteSession(db, 1);
const word = readWord(db, 20);
assert.equal(word?.frequency, 1);
assert.equal(
word?.firstSeen,
Math.floor((BASE_MS + 6 * DAY_MS) / 1000),
'falls back to the subtitle line timestamp',
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('upgrading an older database backfills seen_ms from the subtitle lines', () => {
const { db, dbPath } = createDb(
[
{ session: 1, wordId: 21, dayOffset: 0 },
{ session: 2, wordId: 21, dayOffset: 2 },
],
{ legacyRows: true },
);
try {
// Re-run ensureSchema the way a pre-19 database would be opened.
db.exec('DELETE FROM imm_schema_version');
db.exec(`INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (18, '0')`);
ensureSchema(db);
const rows = db
.prepare('SELECT line_id AS lineId, seen_ms AS seenMs FROM imm_word_line_occurrences')
.all() as Array<{ lineId: number; seenMs: number | null }>;
assert.equal(rows.length, 2);
for (const row of rows) {
assert.ok(row.seenMs, `line ${row.lineId} should have a backfilled timestamp`);
}
assert.deepEqual(
rows.map((row) => row.seenMs).sort((a, b) => Number(a) - Number(b)),
[BASE_MS, BASE_MS + 2 * DAY_MS],
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -4982,3 +4982,145 @@ test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
cleanupDbPath(dbPath);
}
});
test('getVocabularyStats counts the distinct anime each word appeared in', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const startedAtMs = 1_700_000_000_000;
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (11, 'vocab-a', 'Vocab A', ${startedAtMs}, ${startedAtMs}),
(22, 'vocab-b', 'Vocab B', ${startedAtMs}, ${startedAtMs});
`);
const videoIds = [1, 2].map((n) =>
getOrCreateVideoRecord(db, `local:/tmp/vocab-anime-${n}.mkv`, {
canonicalTitle: `Vocab Anime ${n}`,
sourcePath: `/tmp/vocab-anime-${n}.mkv`,
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
}),
);
const sessionIds = videoIds.map(
(videoId) => startSessionRecord(db, videoId, startedAtMs).sessionId,
);
// 猫 spans both titles, twice in the first, so the count has to deduplicate
// by anime rather than just tallying occurrence rows. 犬 stays in one title.
insertFilteredWordOccurrence(db, {
sessionId: sessionIds[0]!,
videoId: videoIds[0]!,
animeId: 11,
lineIndex: 1,
occurrenceCount: 5,
startedAtMs,
word: '猫',
reading: 'ねこ',
});
insertFilteredWordOccurrence(db, {
sessionId: sessionIds[0]!,
videoId: videoIds[0]!,
animeId: 11,
lineIndex: 2,
occurrenceCount: 1,
startedAtMs,
word: '猫',
reading: 'ねこ',
});
insertFilteredWordOccurrence(db, {
sessionId: sessionIds[1]!,
videoId: videoIds[1]!,
animeId: 22,
lineIndex: 3,
occurrenceCount: 4,
startedAtMs,
word: '猫',
reading: 'ねこ',
});
insertFilteredWordOccurrence(db, {
sessionId: sessionIds[0]!,
videoId: videoIds[0]!,
animeId: 11,
lineIndex: 4,
occurrenceCount: 2,
startedAtMs,
word: '犬',
reading: 'いぬ',
});
const rows = getVocabularyStats(db, 10);
const cat = rows.find((row) => row.headword === '猫');
const dog = rows.find((row) => row.headword === '犬');
assert.equal(cat?.animeCount, 2, '猫 was seen in two anime across three lines');
assert.equal(dog?.animeCount, 1, '犬 was seen in one');
assert.equal(cat?.frequency, 10, 'frequency still aggregates across both titles');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getVocabularyStats still applies part-of-speech exclusions', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const startedAtMs = 1_700_000_000_000;
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (11, 'vocab-a', 'Vocab A', ${startedAtMs}, ${startedAtMs});
`);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/vocab-exclude.mkv', {
canonicalTitle: 'Vocab Exclude',
sourcePath: '/tmp/vocab-exclude.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
insertFilteredWordOccurrence(db, {
sessionId,
videoId,
animeId: 11,
lineIndex: 1,
occurrenceCount: 9,
startedAtMs,
word: '走る',
reading: 'はしる',
partOfSpeech: 'verb',
});
insertFilteredWordOccurrence(db, {
sessionId,
videoId,
animeId: 11,
lineIndex: 2,
occurrenceCount: 3,
startedAtMs,
word: '猫',
reading: 'ねこ',
partOfSpeech: 'noun',
});
const all = getVocabularyStats(db, 10);
assert.deepEqual(
all.map((row) => row.headword).sort(),
['猫', '走る'],
'precondition: both parts of speech are present',
);
const nounsOnly = getVocabularyStats(db, 10, ['verb']);
assert.deepEqual(
nounsOnly.map((row) => row.headword),
['猫'],
'excluded part of speech is filtered out even though it ranks higher',
);
assert.equal(nounsOnly[0]?.animeCount, 1, 'surviving rows keep their anime count');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -115,18 +115,29 @@ export function getVocabularyStats(
const whereClause = hasExclude
? `WHERE (part_of_speech IS NULL OR part_of_speech NOT IN (${placeholders}))`
: '';
// The page is selected before the join so `animeCount` is only computed for the
// rows being returned. Aggregating first made every request walk each word's
// entire occurrence history — seconds of blocked event loop on a large library,
// because only the ordering, not the aggregate, decides which rows survive.
const stmt = db.prepare(`
SELECT w.id AS wordId, w.headword, w.word, w.reading,
w.part_of_speech AS partOfSpeech, w.pos1, w.pos2, w.pos3,
w.frequency, w.frequency_rank AS frequencyRank,
w.first_seen AS firstSeen, w.last_seen AS lastSeen,
WITH page AS (
SELECT id, headword, word, reading, part_of_speech, pos1, pos2, pos3,
frequency, frequency_rank, first_seen, last_seen
FROM imm_words
${whereClause}
ORDER BY frequency DESC, id
LIMIT ? OFFSET ?
)
SELECT p.id AS wordId, p.headword, p.word, p.reading,
p.part_of_speech AS partOfSpeech, p.pos1, p.pos2, p.pos3,
p.frequency, p.frequency_rank AS frequencyRank,
p.first_seen AS firstSeen, p.last_seen AS lastSeen,
COUNT(DISTINCT sl.anime_id) AS animeCount
FROM imm_words w
LEFT JOIN imm_word_line_occurrences o ON o.word_id = w.id
FROM page p
LEFT JOIN imm_word_line_occurrences o ON o.word_id = p.id
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id AND sl.anime_id IS NOT NULL
${whereClause ? whereClause.replace('part_of_speech', 'w.part_of_speech') : ''}
GROUP BY w.id
ORDER BY w.frequency DESC LIMIT ? OFFSET ?
GROUP BY p.id
ORDER BY p.frequency DESC, p.id
`);
const visibleRows: VocabularyStatsRow[] = [];
let offset = 0;
@@ -9,14 +9,12 @@ import { PartOfSpeech, type MergedToken } from '../../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
import {
applyLexicalRemovals,
cleanupUnusedCoverArtBlobHash,
deleteSessionsByIds,
findSharedCoverBlobHash,
getAffectedKanjiIdsForSessions,
getAffectedKanjiIdsForVideo,
getAffectedWordIdsForSessions,
getAffectedWordIdsForVideo,
refreshLexicalAggregates,
planLexicalRemovalsForSessions,
planLexicalRemovalsForVideos,
toDbMs,
toDbTimestamp,
} from './query-shared';
@@ -232,12 +230,13 @@ export async function cleanupVocabularyStats(
WHERE id = ?`,
);
const moveOccurrencesStmt = db.prepare(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
SELECT line_id, ?, occurrence_count
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count, seen_ms)
SELECT line_id, ?, occurrence_count, seen_ms
FROM imm_word_line_occurrences
WHERE word_id = ?
ON CONFLICT(line_id, word_id) DO UPDATE SET
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count`,
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(imm_word_line_occurrences.seen_ms, excluded.seen_ms)`,
);
const deleteOccurrencesStmt = db.prepare(
'DELETE FROM imm_word_line_occurrences WHERE word_id = ?',
@@ -484,14 +483,16 @@ export function isVideoWatched(db: DatabaseSync, videoId: number): boolean {
export function deleteSession(db: DatabaseSync, sessionId: number): void {
const sessionIds = [sessionId];
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE');
try {
// Measured inside the write lock: the plan records what the delete removes,
// and applying a plan taken against a different snapshot would subtract the
// wrong totals from imm_words/imm_kanji.
const lexicalRemovals = planLexicalRemovalsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
applyLexicalRemovals(db, lexicalRemovals);
rebuildLifetimeSummariesInTransaction(db);
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT');
@@ -503,14 +504,13 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
if (sessionIds.length === 0) return;
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE');
try {
const lexicalRemovals = planLexicalRemovalsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
applyLexicalRemovals(db, lexicalRemovals);
rebuildLifetimeSummariesInTransaction(db);
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT');
@@ -520,24 +520,83 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
}
}
/**
* Delete an entire library entry: every episode of the anime, all of their
* sessions and derived stats, and the anime row itself.
*
* Mirrors {@link deleteVideo} per episode, but batches the lexical refresh and
* lifetime rebuild into a single transaction so a multi-episode title doesn't
* pay for one full rebuild per episode.
*/
export function deleteAnime(db: DatabaseSync, animeId: number): void {
db.exec('BEGIN IMMEDIATE');
try {
const videoIds = (
db.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?').all(animeId) as Array<{
video_id: number;
}>
).map((row) => row.video_id);
const lexicalRemovals = planLexicalRemovalsForVideos(db, videoIds);
const coverBlobHashes: string[] = [];
const sessionIds: number[] = [];
for (const videoId of videoIds) {
const artRow = db
.prepare('SELECT cover_blob_hash AS coverBlobHash FROM imm_media_art WHERE video_id = ?')
.get(videoId) as { coverBlobHash: string | null } | undefined;
if (artRow?.coverBlobHash) {
coverBlobHashes.push(artRow.coverBlobHash);
}
const sessions = db
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
.all(videoId) as Array<{ session_id: number }>;
sessionIds.push(...sessions.map((session) => session.session_id));
}
deleteSessionsByIds(db, sessionIds);
const deleteLinesStmt = db.prepare('DELETE FROM imm_subtitle_lines WHERE video_id = ?');
const deleteDailyStmt = db.prepare('DELETE FROM imm_daily_rollups WHERE video_id = ?');
const deleteMonthlyStmt = db.prepare('DELETE FROM imm_monthly_rollups WHERE video_id = ?');
const deleteArtStmt = db.prepare('DELETE FROM imm_media_art WHERE video_id = ?');
const deleteVideoStmt = db.prepare('DELETE FROM imm_videos WHERE video_id = ?');
for (const videoId of videoIds) {
deleteLinesStmt.run(videoId);
deleteDailyStmt.run(videoId);
deleteMonthlyStmt.run(videoId);
deleteArtStmt.run(videoId);
deleteVideoStmt.run(videoId);
}
for (const coverBlobHash of new Set(coverBlobHashes)) {
cleanupUnusedCoverArtBlobHash(db, coverBlobHash);
}
db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?').run(animeId);
db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(animeId);
applyLexicalRemovals(db, lexicalRemovals);
rebuildLifetimeSummariesInTransaction(db);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
export function deleteVideo(db: DatabaseSync, videoId: number): void {
const artRow = db
.prepare(
`
db.exec('BEGIN IMMEDIATE');
try {
const artRow = db
.prepare(
`
SELECT cover_blob_hash AS coverBlobHash
FROM imm_media_art
WHERE video_id = ?
`,
)
.get(videoId) as { coverBlobHash: string | null } | undefined;
const affectedWordIds = getAffectedWordIdsForVideo(db, videoId);
const affectedKanjiIds = getAffectedKanjiIdsForVideo(db, videoId);
const sessions = db
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
.all(videoId) as Array<{ session_id: number }>;
)
.get(videoId) as { coverBlobHash: string | null } | undefined;
const lexicalRemovals = planLexicalRemovalsForVideos(db, [videoId]);
const sessions = db
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
.all(videoId) as Array<{ session_id: number }>;
db.exec('BEGIN IMMEDIATE');
try {
deleteSessionsByIds(
db,
sessions.map((session) => session.session_id),
@@ -548,7 +607,7 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
db.prepare('DELETE FROM imm_media_art WHERE video_id = ?').run(videoId);
cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null);
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
applyLexicalRemovals(db, lexicalRemovals);
rebuildLifetimeSummariesInTransaction(db);
db.exec('COMMIT');
} catch (error) {
@@ -203,6 +203,167 @@ export function getAffectedKanjiIdsForVideo(db: DatabaseSync, videoId: number):
return getAffectedIdsForVideo(db, 'kanji', videoId);
}
/** Per-entity totals that a pending delete is about to remove. */
interface LexicalRemoval {
id: number;
removedFrequency: number;
removedFirstSeenMs: number | null;
removedLastSeenMs: number | null;
}
/** What a pending delete removes from `imm_words` and `imm_kanji`. */
export interface LexicalRemovalPlan {
words: LexicalRemoval[];
kanji: LexicalRemoval[];
}
export const EMPTY_LEXICAL_REMOVAL_PLAN: LexicalRemovalPlan = { words: [], kanji: [] };
function collectLexicalRemovals(
db: DatabaseSync,
entity: LexicalEntity,
lineScopeSql: string,
params: number[],
): LexicalRemoval[] {
const table = entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
const col = `${entity}_id`;
return db
.prepare(
`SELECT
o.${col} AS id,
COALESCE(SUM(o.occurrence_count), 0) AS removedFrequency,
MIN(COALESCE(o.seen_ms, sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS removedFirstSeenMs,
MAX(COALESCE(o.seen_ms, sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS removedLastSeenMs
FROM imm_subtitle_lines sl
JOIN ${table} o ON o.line_id = sl.line_id
WHERE ${lineScopeSql}
GROUP BY o.${col}`,
)
.all(...params) as LexicalRemoval[];
}
function planLexicalRemovals(
db: DatabaseSync,
lineScopeSql: string,
params: number[],
): LexicalRemovalPlan {
return {
words: collectLexicalRemovals(db, 'word', lineScopeSql, params),
kanji: collectLexicalRemovals(db, 'kanji', lineScopeSql, params),
};
}
/**
* Measure what deleting these sessions removes from the vocabulary tables.
*
* Must run before the rows are deleted. Reads only the subtitle lines in scope,
* unlike {@link refreshLexicalAggregates}, which re-reads every occurrence of
* every affected word across the whole library.
*/
export function planLexicalRemovalsForSessions(
db: DatabaseSync,
sessionIds: number[],
): LexicalRemovalPlan {
if (sessionIds.length === 0) return EMPTY_LEXICAL_REMOVAL_PLAN;
return planLexicalRemovals(db, `sl.session_id IN (${makePlaceholders(sessionIds)})`, sessionIds);
}
/** Measure what deleting these videos removes from the vocabulary tables. */
export function planLexicalRemovalsForVideos(
db: DatabaseSync,
videoIds: number[],
): LexicalRemovalPlan {
if (videoIds.length === 0) return EMPTY_LEXICAL_REMOVAL_PLAN;
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
}
function toStoredSeenSeconds(ms: number | null): number | null {
if (ms === null || !Number.isFinite(ms)) return null;
return Math.floor(ms / 1000);
}
/**
* Apply a removal plan to the vocabulary aggregates.
*
* Frequencies are adjusted by subtraction, which is exact and touches only the
* affected rows. `first_seen`/`last_seen` only need a rescan when the removed
* lines held the current extreme, and rows whose frequency reaches zero are
* verified against the surviving occurrences before deletion — so stored counts
* that have drifted still converge on the truth instead of dropping a live row.
*/
export function applyLexicalRemovals(db: DatabaseSync, plan: LexicalRemovalPlan): void {
applyRemovalsForEntity(db, 'word', plan.words);
applyRemovalsForEntity(db, 'kanji', plan.kanji);
}
function applyRemovalsForEntity(
db: DatabaseSync,
entity: LexicalEntity,
removals: LexicalRemoval[],
): void {
if (removals.length === 0) return;
const entityTable = entity === 'word' ? 'imm_words' : 'imm_kanji';
const occurrenceTable =
entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
const col = `${entity}_id`;
const selectStmt = db.prepare(
`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen
FROM ${entityTable}
WHERE id = ?`,
);
const updateFrequencyStmt = db.prepare(`UPDATE ${entityTable} SET frequency = ? WHERE id = ?`);
const hasOccurrencesStmt = db.prepare(
`SELECT 1 AS found FROM ${occurrenceTable} WHERE ${col} = ? LIMIT 1`,
);
const deleteStmt = db.prepare(`DELETE FROM ${entityTable} WHERE id = ?`);
const needsExactRefresh: number[] = [];
for (const removal of removals) {
const current = selectStmt.get(removal.id) as {
frequency: number | null;
firstSeen: number | null;
lastSeen: number | null;
} | null;
if (!current) continue;
const nextFrequency = (current.frequency ?? 0) - removal.removedFrequency;
if (nextFrequency <= 0) {
// The rows in scope are already gone by now, so anything still pointing at
// this entity means the stored frequency was stale rather than exhausted.
if (hasOccurrencesStmt.get(removal.id)) {
needsExactRefresh.push(removal.id);
} else {
deleteStmt.run(removal.id);
}
continue;
}
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
const firstSeenMayHaveMoved =
current.firstSeen === null ||
(removedFirstSeen !== null && removedFirstSeen <= current.firstSeen);
const lastSeenMayHaveMoved =
current.lastSeen === null ||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
if (firstSeenMayHaveMoved || lastSeenMayHaveMoved) {
needsExactRefresh.push(removal.id);
continue;
}
updateFrequencyStmt.run(nextFrequency, removal.id);
}
if (entity === 'word') {
refreshWordAggregates(db, needsExactRefresh);
} else {
refreshKanjiAggregates(db, needsExactRefresh);
}
}
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
if (wordIds.length === 0) {
return;
@@ -214,11 +375,18 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
SELECT
w.id AS wordId,
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
MIN(COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS firstSeen,
MAX(COALESCE(sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS lastSeen
MIN(COALESCE(o.seen_ms, (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = o.line_id
))) AS firstSeen,
MAX(COALESCE(o.seen_ms, (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = o.line_id
))) AS lastSeen
FROM imm_words w
LEFT JOIN imm_word_line_occurrences o ON o.word_id = w.id
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE w.id IN (${makePlaceholders(wordIds)})
GROUP BY w.id
`,
@@ -263,11 +431,18 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
SELECT
k.id AS kanjiId,
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
MIN(COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS firstSeen,
MAX(COALESCE(sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS lastSeen
MIN(COALESCE(o.seen_ms, (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = o.line_id
))) AS firstSeen,
MAX(COALESCE(o.seen_ms, (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = o.line_id
))) AS lastSeen
FROM imm_kanji k
LEFT JOIN imm_kanji_line_occurrences o ON o.kanji_id = k.id
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE k.id IN (${makePlaceholders(kanjiIds)})
GROUP BY k.id
`,
+56 -12
View File
@@ -192,6 +192,33 @@ function addColumnIfMissing(
}
}
/**
* Copy each subtitle line's timestamp onto its word/kanji occurrence rows.
*
* Vocabulary aggregates used to be recomputed by joining every occurrence back
* to `imm_subtitle_lines` just to read two timestamps, which meant one random
* read into the widest table per occurrence — the reason deleting a session cost
* seconds on a large library. With the timestamp stored alongside the count, the
* covering index answers those aggregates on its own.
*/
function backfillLexicalOccurrenceSeenMs(db: DatabaseSync): void {
for (const table of ['imm_word_line_occurrences', 'imm_kanji_line_occurrences']) {
addColumnIfMissing(db, table, 'seen_ms', 'INTEGER');
db.exec(`
UPDATE ${table}
SET seen_ms = (
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
FROM imm_subtitle_lines sl
WHERE sl.line_id = ${table}.line_id
)
WHERE seen_ms IS NULL
`);
}
// Superseded by the covering (entity, seen_ms, occurrence_count, line_id) indexes.
db.exec('DROP INDEX IF EXISTS idx_word_line_occurrences_word');
db.exec('DROP INDEX IF EXISTS idx_kanji_line_occurrences_kanji');
}
function dropColumnIfExists(db: DatabaseSync, tableName: string, columnName: string): void {
if (hasColumn(db, tableName, columnName)) {
db.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
@@ -923,6 +950,7 @@ export function ensureSchema(db: DatabaseSync): void {
line_id INTEGER NOT NULL,
word_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, word_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
@@ -933,6 +961,7 @@ export function ensureSchema(db: DatabaseSync): void {
line_id INTEGER NOT NULL,
kanji_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, kanji_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
@@ -1093,6 +1122,7 @@ export function ensureSchema(db: DatabaseSync): void {
line_id INTEGER NOT NULL,
word_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, word_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
@@ -1103,6 +1133,7 @@ export function ensureSchema(db: DatabaseSync): void {
line_id INTEGER NOT NULL,
kanji_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, kanji_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
@@ -1349,13 +1380,19 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE INDEX IF NOT EXISTS idx_subtitle_lines_anime_line
ON imm_subtitle_lines(anime_id, line_index)
`);
if (currentVersion?.schema_version && currentVersion.schema_version < 19) {
backfillLexicalOccurrenceSeenMs(db);
}
// Covering indexes: vocabulary aggregates (frequency, first/last seen) are
// answered from the index alone, without reading the wide subtitle-line rows.
db.exec(`
CREATE INDEX IF NOT EXISTS idx_word_line_occurrences_word
ON imm_word_line_occurrences(word_id, line_id)
CREATE INDEX IF NOT EXISTS idx_word_line_occurrences_word_seen
ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_kanji_line_occurrences_kanji
ON imm_kanji_line_occurrences(kanji_id, line_id)
CREATE INDEX IF NOT EXISTS idx_kanji_line_occurrences_kanji_seen
ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_media_art_cover_blob_hash
@@ -1475,21 +1512,23 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
`),
wordLineOccurrenceUpsertStmt: db.prepare(`
INSERT INTO imm_word_line_occurrences (
line_id, word_id, occurrence_count
line_id, word_id, occurrence_count, seen_ms
) VALUES (
?, ?, ?
?, ?, ?, ?
)
ON CONFLICT(line_id, word_id) DO UPDATE SET
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(imm_word_line_occurrences.seen_ms, excluded.seen_ms)
`),
kanjiLineOccurrenceUpsertStmt: db.prepare(`
INSERT INTO imm_kanji_line_occurrences (
line_id, kanji_id, occurrence_count
line_id, kanji_id, occurrence_count, seen_ms
) VALUES (
?, ?, ?
?, ?, ?, ?
)
ON CONFLICT(line_id, kanji_id) DO UPDATE SET
occurrence_count = imm_kanji_line_occurrences.occurrence_count + excluded.occurrence_count
occurrence_count = imm_kanji_line_occurrences.occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(imm_kanji_line_occurrences.seen_ms, excluded.seen_ms)
`),
videoAnimeIdSelectStmt: db.prepare(`
SELECT anime_id FROM imm_videos
@@ -1630,11 +1669,16 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
const lineId = Number(lineResult.lastInsertRowid);
for (const occurrence of write.wordOccurrences) {
const wordId = incrementWordAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount);
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, currentMs);
}
for (const occurrence of write.kanjiOccurrences) {
const kanjiId = incrementKanjiAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
stmts.kanjiLineOccurrenceUpsertStmt.run(lineId, kanjiId, occurrence.occurrenceCount);
stmts.kanjiLineOccurrenceUpsertStmt.run(
lineId,
kanjiId,
occurrence.occurrenceCount,
currentMs,
);
}
return;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 18;
export const SCHEMA_VERSION = 19;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -190,4 +190,11 @@ export function registerStatsLibraryRoutes(
await tracker.deleteVideo(videoId);
return c.json(statsJson('deleteVideo', { ok: true }));
});
app.delete('/api/stats/anime/:animeId', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
await tracker.deleteAnime(animeId);
return c.json(statsJson('deleteAnime', { ok: true }));
});
}
@@ -0,0 +1,100 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from '../immersion-tracker/sqlite';
import { ensureSchema } from '../immersion-tracker/storage';
import { mergeSnapshotIntoDb } from './merge';
const BASE_MS = 1_700_000_000_000;
const DAY_MS = 86_400_000;
/**
* Build a database holding one anime, one episode, one ended session and a
* single word occurrence.
*
* `legacyOccurrences` reproduces a peer whose schema predates the denormalised
* `seen_ms` column, so the merge has to recover the timestamp from the line.
*/
function buildDb(
dir: string,
name: string,
options: { word: string; seenMs: number; legacyOccurrences: boolean },
): string {
const dbPath = path.join(dir, name);
const db = new Database(dbPath);
ensureSchema(db);
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'key-${name}', 'Show ${name}', ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'video-${name}', 1, 'Episode 1', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'uuid-${name}', 1, '${options.seenMs}', '${options.seenMs + 1000}', 2, ${BASE_MS}, ${BASE_MS});
INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 1, 1, 1, 0, 'line', ${options.seenMs}, ${options.seenMs});
INSERT INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
VALUES (1, '${options.word}', '${options.word}', '', 'noun', '名詞',
${Math.floor(options.seenMs / 1000)}, ${Math.floor(options.seenMs / 1000)}, 1);
`);
db.exec(
options.legacyOccurrences
? 'INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count) VALUES (1, 1, 1)'
: `INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) VALUES (1, 1, 1, ${options.seenMs})`,
);
db.close();
return dbPath;
}
function mergedOccurrences(dbPath: string): Array<{ word: string; seenMs: number | null }> {
const db = new Database(dbPath);
try {
return db
.prepare(
`SELECT w.word AS word, o.seen_ms AS seenMs
FROM imm_word_line_occurrences o
JOIN imm_words w ON w.id = o.word_id
ORDER BY w.word`,
)
.all() as Array<{ word: string; seenMs: number | null }>;
} finally {
db.close();
}
}
for (const legacyOccurrences of [false, true]) {
const label = legacyOccurrences ? 'a peer predating the seen_ms column' : 'a current peer';
test(`sync merge carries occurrence timestamps in from ${label}`, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-merge-occurrences-test-'));
try {
const local = buildDb(dir, 'local.sqlite', {
word: '猫',
seenMs: BASE_MS,
legacyOccurrences: false,
});
const remoteSeenMs = BASE_MS + 5 * DAY_MS;
const remote = buildDb(dir, 'remote.sqlite', {
word: '犬',
seenMs: remoteSeenMs,
legacyOccurrences,
});
mergeSnapshotIntoDb(local, remote);
const rows = mergedOccurrences(local);
assert.equal(rows.length, 2, 'local and remote occurrences both survive the merge');
for (const row of rows) {
assert.ok(row.seenMs, `${row.word} must carry a timestamp so aggregates stay index-only`);
}
assert.equal(
Number(rows.find((row) => row.word === '犬')?.seenMs),
remoteSeenMs,
"the remote line's own timestamp is preserved rather than stamped with merge time",
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
}
+13 -6
View File
@@ -215,12 +215,16 @@ function copySubtitleLines(
'SELECT kanji_id, occurrence_count FROM imm_kanji_line_occurrences WHERE line_id = ?',
);
const insertWordOccurrence = local.query(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) VALUES (?, ?, ?)
ON CONFLICT(line_id, word_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count, seen_ms) VALUES (?, ?, ?, ?)
ON CONFLICT(line_id, word_id) DO UPDATE SET
occurrence_count = occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(seen_ms, excluded.seen_ms)`,
);
const insertKanjiOccurrence = local.query(
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count) VALUES (?, ?, ?)
ON CONFLICT(line_id, kanji_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count, seen_ms) VALUES (?, ?, ?, ?)
ON CONFLICT(line_id, kanji_id) DO UPDATE SET
occurrence_count = occurrence_count + excluded.occurrence_count,
seen_ms = COALESCE(seen_ms, excluded.seen_ms)`,
);
for (const row of rows) {
@@ -241,17 +245,20 @@ function copySubtitleLines(
],
);
summary.subtitleLinesAdded += 1;
// Taken from the line rather than the remote occurrence row so this also
// works when the remote database predates the seen_ms column.
const seenMs = row.CREATED_DATE ?? row.LAST_UPDATE_DATE ?? null;
for (const occurrence of wordOccurrences.all(row.line_id) as SqlRow[]) {
const localWordId = lexicon.resolveWord(Number(occurrence.word_id));
const count = Number(occurrence.occurrence_count);
insertWordOccurrence.run(localLineId, localWordId, count);
insertWordOccurrence.run(localLineId, localWordId, count, seenMs);
lexicon.addWordOccurrences(Number(occurrence.word_id), count);
}
for (const occurrence of kanjiOccurrences.all(row.line_id) as SqlRow[]) {
const localKanjiId = lexicon.resolveKanji(Number(occurrence.kanji_id));
const count = Number(occurrence.occurrence_count);
insertKanjiOccurrence.run(localLineId, localKanjiId, count);
insertKanjiOccurrence.run(localLineId, localKanjiId, count, seenMs);
lexicon.addKanjiOccurrences(Number(occurrence.kanji_id), count);
}
}
+2
View File
@@ -141,6 +141,7 @@ export interface StatsJsonResponseMap {
deleteSessions: StatsOkResponse;
deleteSession: StatsOkResponse;
deleteVideo: StatsOkResponse;
deleteAnime: StatsOkResponse;
anilistSearch: StatsAnilistSearchResult[];
knownWords: string[];
knownWordsSummary: StatsKnownWordsSummary;
@@ -219,6 +220,7 @@ export interface StatsHttpClient {
deleteSession: (sessionId: number) => Promise<void>;
deleteSessions: (sessionIds: number[]) => Promise<void>;
deleteVideo: (videoId: number) => Promise<void>;
deleteAnime: (animeId: number) => Promise<void>;
getKnownWords: () => Promise<string[]>;
getKnownWordsSummary: () => Promise<StatsKnownWordsSummary>;
getAnimeKnownWordsSummary: (animeId: number) => Promise<StatsKnownWordsSummary>;
+2
View File
@@ -1,5 +1,6 @@
import { Suspense, lazy, useCallback, useState } from 'react';
import { DeleteConfirmDialog } from './components/layout/DeleteConfirmDialog';
import { DeleteProgressToast } from './components/common/DeleteProgressToast';
import { TabBar } from './components/layout/TabBar';
import { OverviewTab } from './components/overview/OverviewTab';
import { useExcludedWords } from './hooks/useExcludedWords';
@@ -293,6 +294,7 @@ export function App() {
</Suspense>
) : null}
<DeleteConfirmDialog />
<DeleteProgressToast />
</div>
);
}
+45 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
import { getStatsClient } from '../../hooks/useStatsApi';
import { confirmAnimeDelete } from '../../lib/delete-confirm';
import { epochDayToDate } from '../../lib/formatters';
import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList';
@@ -16,6 +17,8 @@ interface AnimeDetailViewProps {
onBack: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (videoId: number) => void;
/** Called after the whole library entry is deleted, so the caller can refresh. */
onAnimeDeleted?: () => void;
}
type Range = 14 | 30 | 90;
@@ -139,10 +142,14 @@ export function AnimeDetailView({
onBack,
onNavigateToWord,
onOpenEpisodeDetail,
onAnimeDeleted,
}: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
const [coverRetryToken, setCoverRetryToken] = useState(0);
const [isDeletingAnime, setIsDeletingAnime] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const isDeletingAnimeRef = useRef(false);
const knownWordsSummary = useAnimeKnownWords(animeId);
useEffect(() => {
@@ -154,6 +161,40 @@ export function AnimeDetailView({
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
const { detail, episodes, anilistEntries } = data;
const handleDeleteAnime = async () => {
if (isDeletingAnimeRef.current) return;
isDeletingAnimeRef.current = true;
// Cleared up front so cancelling a retry doesn't leave the previous
// attempt's failure on screen.
setDeleteError(null);
let confirmed = false;
try {
confirmed = await confirmAnimeDelete(detail.canonicalTitle, detail.episodeCount);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
isDeletingAnimeRef.current = false;
return;
}
if (!confirmed) {
isDeletingAnimeRef.current = false;
return;
}
setDeleteError(null);
setIsDeletingAnime(true);
try {
await getStatsClient().deleteAnime(animeId);
onAnimeDeleted?.();
onBack();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete this title.');
setIsDeletingAnime(false);
} finally {
isDeletingAnimeRef.current = false;
}
};
return (
<div className="space-y-4">
<button
@@ -168,7 +209,10 @@ export function AnimeDetailView({
anilistEntries={anilistEntries ?? []}
coverRetryToken={coverRetryToken}
onChangeAnilist={() => setShowAnilistSelector(true)}
onDeleteAnime={() => void handleDeleteAnime()}
isDeletingAnime={isDeletingAnime}
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
<EpisodeList
episodes={episodes}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeHeader } from './AnimeHeader';
import { confirmAnimeDelete, setDeleteConfirmPresenter } from '../../lib/delete-confirm';
import type { AnimeDetailData } from '../../types/stats';
const DETAIL: AnimeDetailData['detail'] = {
animeId: 3,
canonicalTitle: 'Project Radio Noise Season 2',
anilistId: 20661,
titleRomaji: 'Toaru Kagaku no Railgun S',
titleEnglish: 'A Certain Scientific Railgun S',
titleNative: null,
description: null,
totalSessions: 1,
totalActiveMs: 960_000,
totalCards: 0,
totalTokensSeen: 1_655,
totalLinesSeen: 300,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
episodeCount: 1,
lastWatchedMs: 1_700_000_000_000,
};
test('AnimeHeader offers a delete-entry action alongside the AniList actions', () => {
const markup = renderToStaticMarkup(
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} />,
);
assert.match(markup, /Delete Entry/);
assert.match(markup, /Delete this title and every session and stat recorded for it/);
});
test('AnimeHeader hides the delete action when no handler is wired', () => {
const markup = renderToStaticMarkup(<AnimeHeader detail={DETAIL} anilistEntries={[]} />);
assert.doesNotMatch(markup, /Delete Entry/);
});
test('AnimeHeader shows a pending state while the entry is being deleted', () => {
const markup = renderToStaticMarkup(
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} isDeletingAnime />,
);
assert.match(markup, /disabled=""/);
assert.match(markup, /animate-spin/);
assert.doesNotMatch(markup, /Delete Entry/);
});
test('confirmAnimeDelete spells out how much data the entry deletion removes', async () => {
const seen: string[] = [];
const restore = setDeleteConfirmPresenter((message) => {
seen.push(message);
return false;
});
try {
await confirmAnimeDelete('Railgun Season 2', 1);
await confirmAnimeDelete('Railgun Season 2', 3);
} finally {
restore();
}
assert.match(seen[0] ?? '', /"Railgun Season 2"/);
assert.match(seen[0] ?? '', /1 episode\b/);
assert.match(seen[1] ?? '', /3 episodes/);
assert.match(seen[0] ?? '', /every session and stat/);
});
@@ -6,6 +6,8 @@ interface AnimeHeaderProps {
anilistEntries: AnilistEntry[];
coverRetryToken?: number;
onChangeAnilist?: () => void;
onDeleteAnime?: () => void;
isDeletingAnime?: boolean;
}
function AnilistButton({ entry }: { entry: AnilistEntry }) {
@@ -32,6 +34,8 @@ export function AnimeHeader({
anilistEntries,
coverRetryToken = 0,
onChangeAnilist,
onDeleteAnime,
isDeletingAnime = false,
}: AnimeHeaderProps) {
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
(t): t is string => t != null && t !== detail.canonicalTitle,
@@ -95,6 +99,25 @@ export function AnimeHeader({
: 'Link to AniList'}
</button>
)}
{onDeleteAnime && (
<button
type="button"
onClick={onDeleteAnime}
disabled={isDeletingAnime}
title="Delete this title and every session and stat recorded for it"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-red/80 hover:bg-ctp-red/15 hover:text-ctp-red transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeletingAnime ? (
<span
aria-hidden="true"
className="h-3 w-3 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
) : (
<span aria-hidden="true">{'\u2715'}</span>
)}
{isDeletingAnime ? 'Deleting\u2026' : 'Delete Entry'}
</button>
)}
</div>
{detail.description && (
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
+2 -1
View File
@@ -53,7 +53,7 @@ export function AnimeTab({
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeTabProps) {
const { anime, loading, error } = useAnimeLibrary();
const { anime, loading, error, reload } = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
@@ -98,6 +98,7 @@ export function AnimeTab({
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
: undefined
}
onAnimeDeleted={reload}
/>
);
}
@@ -1,32 +1,49 @@
interface DeleteProgressToastProps {
/** Number of sessions currently being deleted. The toast is hidden when 0. */
count: number;
}
import { useSyncExternalStore } from 'react';
import { getDeleteProgressSnapshot, subscribeDeleteProgress } from '../../lib/delete-progress';
/**
* Fixed-position toast shown while session deletions are in flight.
* Global "deletion in progress" indicator.
*
* The per-row delete buttons are only visible on hover, so once the confirm
* dialog closes the user has no signal that a (potentially slow) batch delete
* is still running. This stays on screen, independent of hover, until the work
* finishes.
* Mounted once at the app root, outside every tab panel, so it shows no matter
* which view started the delete — per-tab copies were hidden along with their
* `hidden` tab panel, and detail views had no indicator at all. State comes
* from the delete-progress store rather than props so it also survives the
* initiating component unmounting mid-request.
*
* Renders two signals: a sweeping bar pinned to the top edge (visible even when
* the eye is on the content being deleted) and a bottom-right toast naming the
* work.
*/
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
export function DeleteProgressToast() {
const { count, label } = useSyncExternalStore(
subscribeDeleteProgress,
getDeleteProgressSnapshot,
getDeleteProgressSnapshot,
);
if (count <= 0) return null;
const message = count > 1 ? `Deleting ${count} items` : (label ?? 'Deleting');
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
>
<span
<>
<div
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
<span className="text-sm text-ctp-text">
Deleting {count} session{count === 1 ? '' : 's'}&hellip;
</span>
</div>
className="fixed inset-x-0 top-0 z-[2147483646] h-0.5 overflow-hidden bg-ctp-surface1"
>
<div className="h-full w-1/3 animate-indeterminate rounded-full bg-gradient-to-r from-ctp-red via-ctp-peach to-ctp-red" />
</div>
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-[2147483646] flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
>
<span
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
<span className="text-sm text-ctp-text">{message}&hellip;</span>
</div>
</>
);
}
@@ -6,7 +6,6 @@ import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions';
import { TrackingSnapshot } from './TrackingSnapshot';
import { TrendChart } from '../trends/TrendChart';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
@@ -160,8 +159,6 @@ export function OverviewTab({
deletingIds={deletingIds}
isActive={isActive}
/>
<DeleteProgressToast count={deletingIds.size} />
</div>
);
}
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { apiClient } from '../../lib/api-client';
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
@@ -344,8 +343,6 @@ export function SessionsTab({
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div>
)}
<DeleteProgressToast count={deletingSessionIds.size} />
</div>
);
}
+8 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeLibraryItem } from '../types/stats';
@@ -6,6 +6,11 @@ export function useAnimeLibrary() {
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const reload = useCallback(() => {
setReloadToken((token) => token + 1);
}, []);
useEffect(() => {
let cancelled = false;
@@ -23,7 +28,7 @@ export function useAnimeLibrary() {
return () => {
cancelled = true;
};
}, []);
}, [reloadToken]);
return { anime, loading, error };
return { anime, loading, error, reload };
}
+20 -7
View File
@@ -15,6 +15,7 @@ import type {
} from '../types/stats';
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry';
import { trackDelete } from './delete-progress';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
@@ -169,17 +170,29 @@ export const apiClient = {
});
},
deleteSession: async (sessionId: number): Promise<void> => {
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
await trackDelete('Deleting session', () =>
fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' }),
);
},
deleteSessions: async (sessionIds: number[]): Promise<void> => {
await fetchResponse('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
});
const label = `Deleting ${sessionIds.length} session${sessionIds.length === 1 ? '' : 's'}`;
await trackDelete(label, () =>
fetchResponse('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
}),
);
},
deleteVideo: async (videoId: number): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
await trackDelete('Deleting episode', () =>
fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }),
);
},
deleteAnime: async (animeId: number): Promise<void> => {
await trackDelete('Deleting library entry', () =>
fetchResponse(`/api/stats/anime/${animeId}`, { method: 'DELETE' }),
);
},
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
+7
View File
@@ -60,6 +60,13 @@ export function confirmAnimeGroupDelete(title: string, count: number): Promise<b
);
}
export function confirmAnimeDelete(title: string, episodeCount: number): Promise<boolean> {
const episodes = `${episodeCount} episode${episodeCount === 1 ? '' : 's'}`;
return confirmWithStatsNativeDialogLayer(
`Delete "${title}" from your library? This removes ${episodes} plus every session and stat recorded for them.`,
);
}
export function confirmEpisodeDelete(title: string): Promise<boolean> {
return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`);
}
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { renderToStaticMarkup } from 'react-dom/server';
import { DeleteProgressToast } from '../components/common/DeleteProgressToast';
import { apiClient } from './api-client';
import {
beginDeleteTask,
getDeleteProgressSnapshot,
resetDeleteProgress,
subscribeDeleteProgress,
trackDelete,
} from './delete-progress';
test('delete progress store reports the oldest label and notifies subscribers', () => {
resetDeleteProgress();
let notifications = 0;
const unsubscribe = subscribeDeleteProgress(() => {
notifications += 1;
});
try {
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
const endFirst = beginDeleteTask('Deleting session');
const endSecond = beginDeleteTask('Deleting episode');
assert.deepEqual(getDeleteProgressSnapshot(), { count: 2, label: 'Deleting session' });
assert.equal(notifications, 2);
endFirst();
assert.deepEqual(getDeleteProgressSnapshot(), { count: 1, label: 'Deleting episode' });
endFirst();
assert.deepEqual(
getDeleteProgressSnapshot(),
{ count: 1, label: 'Deleting episode' },
'ending the same task twice must not drop another task',
);
endSecond();
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
} finally {
unsubscribe();
resetDeleteProgress();
}
});
test('trackDelete clears the indicator even when the request rejects', async () => {
resetDeleteProgress();
await assert.rejects(
trackDelete('Deleting library entry', async () => {
assert.equal(getDeleteProgressSnapshot().count, 1);
throw new Error('boom');
}),
/boom/,
);
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
});
test('DeleteProgressToast stays hidden while idle and reports active deletes', () => {
resetDeleteProgress();
assert.equal(renderToStaticMarkup(<DeleteProgressToast />), '');
const end = beginDeleteTask('Deleting library entry');
try {
const markup = renderToStaticMarkup(<DeleteProgressToast />);
assert.match(markup, /role="status"/);
assert.match(markup, /Deleting library entry/);
assert.match(markup, /animate-indeterminate/);
} finally {
end();
}
const endFirst = beginDeleteTask('Deleting session');
const endSecond = beginDeleteTask('Deleting episode');
try {
assert.match(renderToStaticMarkup(<DeleteProgressToast />), /Deleting 2 items/);
} finally {
endFirst();
endSecond();
resetDeleteProgress();
}
});
test('the delete indicator is mounted once at the app root, not inside tab panels', () => {
const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relativePath: string): string =>
fs.readFileSync(path.join(srcDir, relativePath), 'utf8');
const app = read('App.tsx');
assert.match(app, /<DeleteProgressToast \/>/);
// Sibling of the confirm dialog: outside every `hidden` tab panel, so the
// indicator survives tab switches and detail-view navigation.
assert.match(app, /<DeleteConfirmDialog \/>\s*<DeleteProgressToast \/>/);
for (const tab of [
'components/overview/OverviewTab.tsx',
'components/sessions/SessionsTab.tsx',
]) {
assert.doesNotMatch(read(tab), /DeleteProgressToast/, `${tab} must not mount its own toast`);
}
});
test('every api client delete registers with the global progress indicator', async () => {
const originalFetch = globalThis.fetch;
const seenCounts: number[] = [];
globalThis.fetch = (async () => {
seenCounts.push(getDeleteProgressSnapshot().count);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}) as typeof globalThis.fetch;
try {
resetDeleteProgress();
await apiClient.deleteSession(1);
await apiClient.deleteSessions([1, 2]);
await apiClient.deleteVideo(3);
await apiClient.deleteAnime(4);
assert.deepEqual(seenCounts, [1, 1, 1, 1]);
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
} finally {
globalThis.fetch = originalFetch;
resetDeleteProgress();
}
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Tiny external store tracking in-flight delete requests.
*
* Every delete goes through the API client, which registers here, so a single
* globally mounted indicator can report progress no matter which tab, detail
* view or overlay window started the work. Keeping the state outside React
* means the indicator survives the deleting component unmounting mid-request
* (navigating back after deleting a library entry, for example).
*/
export interface DeleteProgressSnapshot {
/** Number of delete requests currently in flight. */
count: number;
/** Label for the oldest in-flight request, or null when idle. */
label: string | null;
}
const IDLE_SNAPSHOT: DeleteProgressSnapshot = { count: 0, label: null };
const activeTasks = new Map<number, string>();
const listeners = new Set<() => void>();
let nextTaskId = 1;
let snapshot: DeleteProgressSnapshot = IDLE_SNAPSHOT;
function publish(): void {
if (activeTasks.size === 0) {
snapshot = IDLE_SNAPSHOT;
} else {
const [oldestLabel] = activeTasks.values();
snapshot = { count: activeTasks.size, label: oldestLabel ?? null };
}
for (const listener of listeners) listener();
}
export function subscribeDeleteProgress(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getDeleteProgressSnapshot(): DeleteProgressSnapshot {
return snapshot;
}
/** Register a delete as started. Returns the function that ends it. */
export function beginDeleteTask(label: string): () => void {
const taskId = nextTaskId++;
activeTasks.set(taskId, label);
publish();
let ended = false;
return () => {
if (ended) return;
ended = true;
activeTasks.delete(taskId);
publish();
};
}
/** Run a delete request with the global progress indicator active. */
export async function trackDelete<T>(label: string, run: () => Promise<T>): Promise<T> {
const end = beginDeleteTask(label);
try {
return await run();
} finally {
end();
}
}
/** Test helper: drop every in-flight task so cases don't leak into each other. */
export function resetDeleteProgress(): void {
activeTasks.clear();
publish();
}
+16 -2
View File
@@ -80,7 +80,7 @@ body.overlay-mode #root {
}
/* Tab content entrance animation */
@keyframes fadeSlideIn {
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(6px);
@@ -92,5 +92,19 @@ body.overlay-mode #root {
}
.animate-fade-in {
animation: fadeSlideIn 0.25s ease-out;
animation: fade-slide-in 0.25s ease-out;
}
/* Indeterminate progress sweep for the global delete indicator */
@keyframes indeterminate-sweep {
from {
transform: translateX(-100%);
}
to {
transform: translateX(400%);
}
}
.animate-indeterminate {
animation: indeterminate-sweep 1.1s ease-in-out infinite;
}