Compare commits

..

7 Commits

69 changed files with 1675 additions and 984 deletions
-5
View File
@@ -1,5 +0,0 @@
type: internal
area: docs
- Excluded the `/main/` and `/v/<version>/` docs trees from search indexing with a self-referential canonical, `noindex,follow`, and a matching `X-Robots-Tag` header, so crawlers spend their budget on the current docs instead of ~30 archived copies of every page.
- Restored `<lastmod>` dates in the docs sitemap, which were silently dropped because production builds render from an untracked release snapshot.
@@ -1,5 +0,0 @@
type: fixed
area: overlay
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change.
- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed system-wide mouse lag on Windows while SubMiner is running: the overlay no longer installs Electron's global mouse hook for click-through forwarding, and the mpv window tracker no longer blocks the app on repeated PowerShell command-line lookups.
@@ -0,0 +1,6 @@
type: fixed
area: stats
- Fixed Vocabulary totals and charts counting only the first browsing page instead of all tracked vocabulary, without delaying the rest of the page.
- New-word history now uses permanent daily lexical rollups, backfilled in the background and repaired when tracked material is removed or reprocessed.
- Calendar-day chart labels now preserve the recorded local date in time zones west of UTC.
+14 -49
View File
@@ -1,4 +1,3 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, statSync } from 'node:fs'; import { existsSync, readFileSync, statSync } from 'node:fs';
import { extname, join, posix, resolve, sep } from 'node:path'; import { extname, join, posix, resolve, sep } from 'node:path';
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress'; import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
@@ -27,9 +26,6 @@ function optionalEnv(value: string | undefined): string | undefined {
const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/'); const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR); const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd(); const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
// The tracked `docs-site/` checkout, which stays a git working tree even when
// `docsSourceDir` points at an untracked release snapshot. Used for git lookups only.
const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd();
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL)); const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION); const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0'; const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
@@ -86,18 +82,15 @@ function pageToRoute(page: string): string | null {
return route ? `/${route}` : '/'; return route ? `/${route}` : '/';
} }
// Only the root channel is indexable. `main` and every /v/<version>/ archive are
// near-verbatim copies of it, so they own their URL via a self-referential canonical
// and are excluded from the index instead of being consolidated onto root. Uniform
// self-canonical plus noindex avoids mixing noindex with a cross-page canonical,
// which Google treats as a conflicting signal.
const isIndexableChannel = channel === 'stable-root';
function pageToCanonicalHref(page: string): string | null { function pageToCanonicalHref(page: string): string | null {
const route = pageToRoute(page); const route = pageToRoute(page);
if (!route) return null; if (!route) return null;
if (!isIndexableChannel) { if (channel === 'main') {
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
}
if (channel === 'stable-archive' && docsVersion !== latestStable) {
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`; return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
} }
@@ -113,9 +106,7 @@ function transformPageHead({ page }: TransformContext): HeadConfig[] {
const href = pageToCanonicalHref(page); const href = pageToCanonicalHref(page);
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : []; const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
// Crawlable so links still pass through, but out of the index: ~30 archived copies if (channel === 'main') {
// of every page otherwise soak up the crawl budget the current docs need.
if (!isIndexableChannel) {
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]); head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
} }
@@ -296,39 +287,6 @@ const versionItems = [
})), })),
]; ];
function sitemapUrlToPage(url: string): string {
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
return route ? `${route}.md` : 'index.md';
}
// VitePress derives <lastmod> by running `git log` inside its source dir. Production
// builds point that at an untracked snapshot of the release tag, so the lookup comes
// back empty and the sitemap ships with no dates at all. Resolve it from the tracked
// checkout at the ref being built instead.
function lastModifiedFor(url: string): string | undefined {
const ref = docsVersion && docsVersion !== 'main' ? docsVersion : 'HEAD';
const result = spawnSync('git', ['log', '-1', '--format=%cI', ref, '--', sitemapUrlToPage(url)], {
cwd: repoDocsDir,
encoding: 'utf8',
});
return (result.status === 0 && result.stdout.trim()) || undefined;
}
// Only the root channel publishes a sitemap. Archived and `main` builds would emit
// their own copies listing the same canonical URLs, which just advertises the
// duplicate trees we are trying to keep out of the index.
const sitemap: UserConfig['sitemap'] = isIndexableChannel
? {
hostname: DOCS_HOSTNAME,
transformItems(items) {
return items
.filter((item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`)
.map((item) => ({ ...item, lastmod: item.lastmod ?? lastModifiedFor(item.url) }));
},
}
: undefined;
const nav: DefaultTheme.NavItem[] = [ const nav: DefaultTheme.NavItem[] = [
{ text: 'Home', link: '/' }, { text: 'Home', link: '/' },
{ text: 'Get Started', link: '/installation' }, { text: 'Get Started', link: '/installation' },
@@ -461,7 +419,14 @@ const config: UserConfig = {
appearance: 'dark', appearance: 'dark',
cleanUrls: true, cleanUrls: true,
metaChunk: true, metaChunk: true,
sitemap, sitemap: {
hostname: DOCS_HOSTNAME,
transformItems(items) {
return items.filter(
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
);
},
},
transformHead: transformPageHead, transformHead: transformPageHead,
lastUpdated: true, lastUpdated: true,
srcExclude: ['subagents/**', 'README.md'], srcExclude: ['subagents/**', 'README.md'],
+1 -3
View File
@@ -38,10 +38,8 @@ bun run docs:dev
The public docs root is stable-only: The public docs root is stable-only:
- `/` serves the latest stable release docs. - `/` serves the latest stable release docs.
- `/main/` serves development docs from `main`. - `/main/` serves development docs from `main` and is marked `noindex,follow`.
- `/v/<version>/` serves stable release archives. - `/v/<version>/` serves stable release archives.
- Prerelease tags do not update the docs site. - Prerelease tags do not update the docs site.
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, and the generated `_headers` file repeats that as an `X-Robots-Tag`. They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments. Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
+3 -1
View File
@@ -82,7 +82,7 @@ Expandable session history with new-word activity, cumulative totals, and pause/
#### Vocabulary #### Vocabulary
Top repeated words (click a bar to open the word), new-word timeline, cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons. The summary cards show all unique vocabulary and kanji recorded in the local tracking database; **New This Week** is the only weekly figure and uses a rolling seven-day window. The word and kanji tables load first while those complete totals calculate separately. Top Repeated Words and New Words by Day use complete tracking history rather than the table's browsing page; new-word history is maintained as a permanent daily lexical rollup, including retroactive corrections when tracked material is removed or reprocessed. On the first launch after upgrading, that history is built in the background and the chart refreshes when it is ready. The rest of the tab includes cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
![Stats Vocabulary](/screenshots/stats-vocabulary.png) ![Stats Vocabulary](/screenshots/stats-vocabulary.png)
@@ -180,6 +180,7 @@ In practice:
- Anime and episode pages keep lifetime totals from summary tables while session drill-down still reads retained sessions directly. With the current defaults, both are kept forever. - Anime and episode pages keep lifetime totals from summary tables while session drill-down still reads retained sessions directly. With the current defaults, both are kept forever.
- Trends can read the full available history because daily/monthly rollups are also kept forever by default. - Trends can read the full available history because daily/monthly rollups are also kept forever by default.
- Vocabulary and kanji totals are cumulative and not bounded by the raw session retention knobs. - Vocabulary and kanji totals are cumulative and not bounded by the raw session retention knobs.
- New-word charts use their own permanent lexical daily rollups, which are not pruned by activity-rollup retention.
## Storage / Performance Model ## Storage / Performance Model
@@ -349,6 +350,7 @@ Rollup tables:
- `imm_daily_rollups` - `imm_daily_rollups`
- `imm_monthly_rollups` - `imm_monthly_rollups`
- `imm_lexical_daily_rollups` - permanent first-discovery counts for vocabulary and kanji chart history
- `imm_rollup_state` - incremental rollup progress bookkeeping - `imm_rollup_state` - incremental rollup progress bookkeeping
Vocabulary tables: Vocabulary tables:
+21 -49
View File
@@ -56,43 +56,34 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' }, { rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
]); ]);
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]); expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
expect(mainDocsConfig.sitemap).toBeUndefined();
process.env.SUBMINER_DOCS_CHANNEL = previousChannel; process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase; process.env.SUBMINER_DOCS_BASE = previousBase;
}); });
test.each([ test('latest stable archive canonical points to root equivalent', async () => {
['latest stable', 'v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'], const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
['superseded', 'v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'], const previousBase = process.env.SUBMINER_DOCS_BASE;
])( const previousVersion = process.env.SUBMINER_DOCS_VERSION;
'%s archive keeps a self-referential canonical and stays out of the index', const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
async (_label, version, base, expectedCanonical) => { process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL; process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
const previousBase = process.env.SUBMINER_DOCS_BASE; process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
const previousVersion = process.env.SUBMINER_DOCS_VERSION; process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE; const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = base;
process.env.SUBMINER_DOCS_VERSION = version;
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
try {
const { default: archiveConfig } = await import(`./.vitepress/config?archive-${version}`);
const head = await archiveConfig.transformHead?.(makeTransformContext('usage.md')); const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
expect(head).toContainEqual(['link', { rel: 'canonical', href: expectedCanonical }]); expect(head).toContainEqual([
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]); 'link',
// A sitemap here would advertise the archive tree we just excluded. { rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
expect(archiveConfig.sitemap).toBeUndefined(); ]);
} finally {
process.env.SUBMINER_DOCS_CHANNEL = previousChannel; process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase; process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion; process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest; process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
} });
},
);
test('stable archive theme links stay on the selected version', async () => { test('stable archive theme links stay on the selected version', async () => {
const previousCwd = process.cwd(); const previousCwd = process.cwd();
@@ -442,22 +433,3 @@ test('docs sitemap excludes duplicate README page from indexable URLs', async ()
expect(transformedItems?.map((item) => item.url)).toEqual(['', 'usage']); expect(transformedItems?.map((item) => item.url)).toEqual(['', 'usage']);
}); });
test('docs sitemap dates every URL from the tracked checkout', async () => {
const previousRepoDir = process.env.SUBMINER_DOCS_REPO_DIR;
// Production builds render from an untracked snapshot, so the date has to come from
// the real checkout rather than VitePress's own srcDir git lookup.
process.env.SUBMINER_DOCS_REPO_DIR = docsSiteDir;
try {
const { default: sitemapConfig } = await import('./.vitepress/config?sitemap-lastmod');
const items = await sitemapConfig.sitemap?.transformItems?.([{ url: '' }, { url: 'usage' }]);
expect(items).toHaveLength(2);
for (const item of items ?? []) {
expect(item.lastmod).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}
} finally {
process.env.SUBMINER_DOCS_REPO_DIR = previousRepoDir;
}
});
@@ -129,11 +129,7 @@ coming and prefetching would otherwise idle for the rest of the cue.
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
overlay window remained mapped above mpv. overlay window remained mapped above mpv.
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and - Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
the Linux cursor-poll fallback, not bounding-shape clipping. Note that on Windows click-through the Linux cursor-poll fallback, not bounding-shape clipping.
must go through `applyOverlayClickThrough()` (`src/core/services/overlay-click-through.ts`),
which omits `forward: true` there: Electron implements forwarding with a global low-level mouse
hook that lags mouse input system-wide whenever the main thread stalls; the Windows cursor poll
handles overlay wake-up instead.
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical - Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
interaction state is already inactive. The next cursor-poll tick must still reapply interaction state is already inactive. The next cursor-poll tick must still reapply
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep `setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
+2 -5
View File
@@ -106,11 +106,8 @@ function M.create(ctx)
local function get_subtitle_ass_property() local function get_subtitle_ass_property()
local ass_text = mp.get_property("sub-text/ass") local ass_text = mp.get_property("sub-text/ass")
if ass_text ~= nil then if type(ass_text) == "string" and ass_text ~= "" then
if type(ass_text) == "string" and ass_text ~= "" then return ass_text
return ass_text
end
return nil
end end
ass_text = mp.get_property("sub-text-ass") ass_text = mp.get_property("sub-text-ass")
if type(ass_text) == "string" and ass_text ~= "" then if type(ass_text) == "string" and ass_text ~= "" then
+3 -3
View File
@@ -232,7 +232,7 @@ function M.create(ctx)
elseif action_id == "triggerFieldGrouping" then elseif action_id == "triggerFieldGrouping" then
return { "--trigger-field-grouping" } return { "--trigger-field-grouping" }
elseif action_id == "triggerSubsync" then elseif action_id == "triggerSubsync" then
return { "--session-action", '{"actionId":"triggerSubsync"}' } return { "--trigger-subsync" }
elseif action_id == "mineSentence" then elseif action_id == "mineSentence" then
return { "--mine-sentence" } return { "--mine-sentence" }
elseif action_id == "mineSentenceMultiple" then elseif action_id == "mineSentenceMultiple" then
@@ -251,7 +251,7 @@ function M.create(ctx)
elseif action_id == "markWatched" then elseif action_id == "markWatched" then
return { "--mark-watched" } return { "--mark-watched" }
elseif action_id == "openRuntimeOptions" then elseif action_id == "openRuntimeOptions" then
return { "--session-action", '{"actionId":"openRuntimeOptions"}' } return { "--open-runtime-options" }
elseif action_id == "openJimaku" then elseif action_id == "openJimaku" then
return { "--open-jimaku" } return { "--open-jimaku" }
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
@@ -259,7 +259,7 @@ function M.create(ctx)
elseif action_id == "openYoutubePicker" then elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" } return { "--open-youtube-picker" }
elseif action_id == "openSessionHelp" then elseif action_id == "openSessionHelp" then
return { "--session-action", '{"actionId":"openSessionHelp"}' } return { "--open-session-help" }
elseif action_id == "openCharacterDictionaryManager" then elseif action_id == "openCharacterDictionaryManager" then
return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' } return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
elseif action_id == "openControllerSelect" then elseif action_id == "openControllerSelect" then
+1 -13
View File
@@ -4,7 +4,6 @@ function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local input = ctx.input local input = ctx.input
local process = ctx.process local process = ctx.process
local state = ctx.state
local subminer_log = ctx.log.subminer_log local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd local show_osd = ctx.log.show_osd
@@ -94,18 +93,7 @@ function M.create(ctx)
if not ensure_binary_for_menu() then if not ensure_binary_for_menu() then
return return
end end
process.run_binary_command_async({ process.run_control_command_async("open-session-help")
state.binary_path,
"--session-action",
'{"actionId":"openSessionHelp"}',
}, function(ok, result, error)
if ok then
return
end
local reason = error or (result and result.stderr) or "unknown error"
subminer_log("warn", "session-bindings", "Session action failed: " .. tostring(reason))
show_osd("Session action failed")
end)
end) end)
end end
-13
View File
@@ -35,17 +35,6 @@ const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache');
const maxCloudflareFiles = 20_000; const maxCloudflareFiles = 20_000;
const maxCloudflareFileBytes = 25 * 1024 * 1024; const maxCloudflareFileBytes = 25 * 1024 * 1024;
// Cloudflare Pages header rules for the whole deployment. Mirrors the `noindex,follow`
// meta tag the non-root channels emit, so the duplicate trees stay out of the index
// even for responses a crawler takes without parsing the HTML.
const deployHeaders = `# Generated by scripts/build-versioned-docs.ts. Do not edit by hand.
/main/*
X-Robots-Tag: noindex, follow
/v/*
X-Robots-Tag: noindex, follow
`;
function run( function run(
command: string, command: string,
args: string[], args: string[],
@@ -184,7 +173,6 @@ function buildDocs(options: {
SUBMINER_DOCS_BASE: options.base, SUBMINER_DOCS_BASE: options.base,
SUBMINER_DOCS_OUT_DIR: options.outDir, SUBMINER_DOCS_OUT_DIR: options.outDir,
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite, SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
SUBMINER_DOCS_REPO_DIR: currentDocsSite,
SUBMINER_DOCS_CHANNEL: options.channel, SUBMINER_DOCS_CHANNEL: options.channel,
SUBMINER_DOCS_VERSION: options.version ?? '', SUBMINER_DOCS_VERSION: options.version ?? '',
SUBMINER_DOCS_LATEST_STABLE: options.latestStable, SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
@@ -390,7 +378,6 @@ function main() {
}); });
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`); writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(join(aggregateOutDir, '_headers'), deployHeaders);
assertCloudflarePagesLimits(aggregateOutDir); assertCloudflarePagesLimits(aggregateOutDir);
const prunedArchives = pruneArchiveCacheGenerations({ const prunedArchives = pruneArchiveCacheGenerations({
cacheRoot: archiveCacheRoot, cacheRoot: archiveCacheRoot,
@@ -284,6 +284,22 @@ function createMockTracker(
getSessionTimeline: async () => [], getSessionTimeline: async () => [],
getSessionEvents: async () => [], getSessionEvents: async () => [],
getVocabularyStats: async () => VOCABULARY_STATS, getVocabularyStats: async () => VOCABULARY_STATS,
getVocabularySummary: async () => ({
uniqueWords: 501,
uniqueWordsWithoutNames: 500,
uniqueKanji: 201,
newThisWeek: 7,
newThisWeekWithoutNames: 6,
knownWordCount: 250,
knownWordCountWithoutNames: 249,
}),
getVocabularyChartData: async () => ({
ready: true,
topWords: [{ wordId: 1, headword: 'する', frequency: 50 }],
topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }],
newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }],
newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }],
}),
getStatsExcludedWords: async () => [], getStatsExcludedWords: async () => [],
replaceStatsExcludedWords: async () => {}, replaceStatsExcludedWords: async () => {},
getKanjiStats: async () => KANJI_STATS, getKanjiStats: async () => KANJI_STATS,
@@ -711,6 +727,23 @@ describe('stats server API routes', () => {
assert.equal(body[0].headword, 'する'); assert.equal(body[0].headword, 'する');
}); });
it('GET /api/stats/vocabulary/summary returns database-wide card totals', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/vocabulary/summary');
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
uniqueWords: 501,
uniqueWordsWithoutNames: 500,
uniqueKanji: 201,
newThisWeek: 7,
newThisWeekWithoutNames: 6,
knownWordCount: 250,
knownWordCountWithoutNames: 249,
});
});
it('GET /api/stats/kanji returns kanji frequency data', async () => { it('GET /api/stats/kanji returns kanji frequency data', async () => {
const app = createStatsApp(createMockTracker()); const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/kanji'); const res = await app.request('/api/stats/kanji');
@@ -559,6 +559,56 @@ test('fresh tracker DB creates lifetime summary tables', async () => {
} }
}); });
test('fresh tracker DB skips lexical rollup backfill work', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let backfillRuns = 0;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath }, {
runLexicalRollupBackfillTask: async () => {
backfillRuns += 1;
},
} as never);
assert.equal(backfillRuns, 0);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('tracker starts the injected lexical rollup backfill when it is pending', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let backfillRuns = 0;
try {
const setupDb = new Database(dbPath);
const { ensureSchema } = await import('./immersion-tracker/storage');
ensureSchema(setupDb);
setupDb
.prepare(
`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = 'lexical_daily_rollups_ready'`,
)
.run();
setupDb.close();
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath }, {
runLexicalRollupBackfillTask: async () => {
backfillRuns += 1;
},
} as never);
assert.equal(backfillRuns, 1);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('startup backfills lifetime summaries when retained sessions exist but summary tables are empty', async () => { test('startup backfills lifetime summaries when retained sessions exist but summary tables are empty', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
@@ -58,6 +58,7 @@ import {
getSessionEvents, getSessionEvents,
getSimilarWords, getSimilarWords,
getStatsExcludedWords, getStatsExcludedWords,
getVocabularyChartData,
getVocabularyStats, getVocabularyStats,
replaceStatsExcludedWords, replaceStatsExcludedWords,
searchSubtitleSentences, searchSubtitleSentences,
@@ -96,6 +97,12 @@ import {
DeleteMaintenanceWorkerRuntime, DeleteMaintenanceWorkerRuntime,
type RunDeleteMaintenanceTask, type RunDeleteMaintenanceTask,
} from './immersion-tracker/delete-maintenance-worker-runtime'; } from './immersion-tracker/delete-maintenance-worker-runtime';
import {
VocabularySummaryWorkerRuntime,
type RunVocabularySummaryTask,
} from './immersion-tracker/vocabulary-summary-worker-runtime';
import { LexicalRollupWorkerRuntime } from './immersion-tracker/lexical-rollup-worker-runtime';
import { areLexicalDailyRollupsReady } from './immersion-tracker/lexical-rollups';
import { DeleteMaintenanceScheduler } from './immersion-tracker/delete-maintenance-scheduler'; import { DeleteMaintenanceScheduler } from './immersion-tracker/delete-maintenance-scheduler';
import { import {
cleanupDuplicateSubtitleLines, cleanupDuplicateSubtitleLines,
@@ -185,6 +192,7 @@ import {
type StatsExcludedWordRow, type StatsExcludedWordRow,
type StreakCalendarRow, type StreakCalendarRow,
type VocabularyCleanupSummary, type VocabularyCleanupSummary,
type VocabularyStatsSummary,
type WatchTimePerAnimeRow, type WatchTimePerAnimeRow,
type WordAnimeAppearanceRow, type WordAnimeAppearanceRow,
type WordDetailRow, type WordDetailRow,
@@ -407,6 +415,12 @@ export class ImmersionTrackerService {
private readonly dbPath: string; private readonly dbPath: string;
private readonly writeLock = { locked: false }; private readonly writeLock = { locked: false };
private readonly destroyDeleteMaintenanceRunner: () => void; private readonly destroyDeleteMaintenanceRunner: () => void;
private readonly runVocabularySummaryTask: (
knownWords: ReadonlySet<string> | null,
) => Promise<VocabularyStatsSummary>;
private readonly destroyVocabularySummaryRunner: () => void;
private readonly runLexicalRollupBackfillTask: () => Promise<void>;
private readonly destroyLexicalRollupBackfillRunner: () => void;
private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler; private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler;
private flushTimer: ReturnType<typeof setTimeout> | null = null; private flushTimer: ReturnType<typeof setTimeout> | null = null;
private maintenanceTimer: ReturnType<typeof setInterval> | null = null; private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
@@ -434,6 +448,10 @@ export class ImmersionTrackerService {
dependencies: { dependencies: {
runDeleteMaintenanceTask?: RunDeleteMaintenanceTask; runDeleteMaintenanceTask?: RunDeleteMaintenanceTask;
destroyDeleteMaintenanceRunner?: () => void; destroyDeleteMaintenanceRunner?: () => void;
runVocabularySummaryTask?: RunVocabularySummaryTask;
destroyVocabularySummaryRunner?: () => void;
runLexicalRollupBackfillTask?: (dbPath: string) => Promise<void>;
destroyLexicalRollupBackfillRunner?: () => void;
} = {}, } = {},
) { ) {
this.dbPath = options.dbPath; this.dbPath = options.dbPath;
@@ -460,6 +478,27 @@ export class ImmersionTrackerService {
if (!this.isDestroyed && this.queue.length > 0) this.scheduleFlush(0); if (!this.isDestroyed && this.queue.length > 0) this.scheduleFlush(0);
}, },
}); });
if (dependencies.runVocabularySummaryTask) {
this.runVocabularySummaryTask = (knownWords) =>
dependencies.runVocabularySummaryTask!(this.dbPath, knownWords);
this.destroyVocabularySummaryRunner =
dependencies.destroyVocabularySummaryRunner ?? (() => {});
} else {
const vocabularySummaryRuntime = new VocabularySummaryWorkerRuntime();
this.runVocabularySummaryTask = (knownWords) =>
vocabularySummaryRuntime.run(this.dbPath, knownWords);
this.destroyVocabularySummaryRunner = () => vocabularySummaryRuntime.destroy();
}
if (dependencies.runLexicalRollupBackfillTask) {
this.runLexicalRollupBackfillTask = () =>
dependencies.runLexicalRollupBackfillTask!(this.dbPath);
this.destroyLexicalRollupBackfillRunner =
dependencies.destroyLexicalRollupBackfillRunner ?? (() => {});
} else {
const lexicalRollupRuntime = new LexicalRollupWorkerRuntime();
this.runLexicalRollupBackfillTask = () => lexicalRollupRuntime.run(this.dbPath);
this.destroyLexicalRollupBackfillRunner = () => lexicalRollupRuntime.destroy();
}
const parentDir = path.dirname(this.dbPath); const parentDir = path.dirname(this.dbPath);
if (!fs.existsSync(parentDir)) { if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true }); fs.mkdirSync(parentDir, { recursive: true });
@@ -519,6 +558,14 @@ export class ImmersionTrackerService {
this.db = new Database(this.dbPath); this.db = new Database(this.dbPath);
applyPragmas(this.db); applyPragmas(this.db);
ensureSchema(this.db); ensureSchema(this.db);
if (!areLexicalDailyRollupsReady(this.db)) {
void this.runLexicalRollupBackfillTask().catch((error: unknown) => {
this.logger.warn(
'Lexical daily rollup backfill failed; it will retry on next startup',
error,
);
});
}
const reconciledSessions = reconcileStaleActiveSessions(this.db); const reconciledSessions = reconcileStaleActiveSessions(this.db);
if (reconciledSessions > 0) { if (reconciledSessions > 0) {
this.logger.info( this.logger.info(
@@ -565,6 +612,8 @@ export class ImmersionTrackerService {
this.isDestroyed = true; this.isDestroyed = true;
this.deleteMaintenanceScheduler.destroy(); this.deleteMaintenanceScheduler.destroy();
this.destroyDeleteMaintenanceRunner(); this.destroyDeleteMaintenanceRunner();
this.destroyVocabularySummaryRunner();
this.destroyLexicalRollupBackfillRunner();
this.db.close(); this.db.close();
} }
@@ -634,6 +683,14 @@ export class ImmersionTrackerService {
return getVocabularyStats(this.db, limit, excludePos); return getVocabularyStats(this.db, limit, excludePos);
} }
async getVocabularySummary(knownWords: ReadonlySet<string> | null) {
return this.runVocabularySummaryTask(knownWords);
}
async getVocabularyChartData() {
return getVocabularyChartData(this.db);
}
async getStatsExcludedWords(): Promise<StatsExcludedWordRow[]> { async getStatsExcludedWords(): Promise<StatsExcludedWordRow[]> {
return getStatsExcludedWords(this.db); return getStatsExcludedWords(this.db);
} }
@@ -31,6 +31,7 @@ import {
getKanjiOccurrences, getKanjiOccurrences,
getSessionSummaries, getSessionSummaries,
getVocabularyStats, getVocabularyStats,
getVocabularySummary,
getKanjiStats, getKanjiStats,
getSessionEvents, getSessionEvents,
getSessionTimeline, getSessionTimeline,
@@ -1875,6 +1876,88 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
} }
}); });
test('getVocabularySummary counts every tracked vocabulary row instead of a display page', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const nowSec = Math.floor(Date.now() / 1000);
const insertWord = db.prepare(`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3,
first_seen, last_seen, frequency
) VALUES (?, ?, '', 'noun', '名詞', '一般', '', ?, ?, 1)
`);
const insertKanji = db.prepare(`
INSERT INTO imm_kanji (kanji, first_seen, last_seen, frequency)
VALUES (?, ?, ?, 1)
`);
for (let index = 0; index < 501; index += 1) {
insertWord.run(`単語${index}`, `単語${index}`, nowSec - 8 * 86_400, nowSec - 8 * 86_400);
}
for (let index = 0; index < 201; index += 1) {
insertKanji.run(
String.fromCodePoint(0x4e00 + index),
nowSec - 8 * 86_400,
nowSec - 8 * 86_400,
);
}
insertWord.run('今週', '今週', nowSec - 86_400, nowSec - 86_400);
assert.deepEqual(getVocabularySummary(db, new Set(['単語0', '今週']), nowSec * 1000), {
uniqueWords: 502,
uniqueWordsWithoutNames: 502,
uniqueKanji: 201,
newThisWeek: 1,
newThisWeekWithoutNames: 1,
knownWordCount: 2,
knownWordCountWithoutNames: 2,
});
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getVocabularySummary applies vocabulary exclusions and Hide Names totals', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertWord = db.prepare(`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3,
first_seen, last_seen, frequency
) VALUES (?, ?, '', 'noun', '名詞', ?, '', 1, 1, 1)
`);
insertWord.run('猫', '猫', '一般');
insertWord.run('太郎', '太郎', '固有名詞');
insertWord.run('東京', '東京都', '一般');
db.prepare(
`
INSERT INTO imm_stats_excluded_words (headword, word, reading)
VALUES ('東京', '東京', '')
`,
).run();
assert.deepEqual(getVocabularySummary(db, new Set(['猫', '太郎', '東京']), 9 * 86_400_000), {
uniqueWords: 2,
uniqueWordsWithoutNames: 1,
uniqueKanji: 0,
newThisWeek: 0,
newThisWeekWithoutNames: 0,
knownWordCount: 2,
knownWordCountWithoutNames: 1,
});
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => { test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = openTestDb(dbPath); const db = openTestDb(dbPath);
@@ -0,0 +1,103 @@
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 {
LexicalRollupWorkerRuntime,
resolveLexicalRollupWorkerPath,
} from './lexical-rollup-worker-runtime';
import { areLexicalDailyRollupsReady } from './lexical-rollups';
import { Database } from './sqlite';
import { applyPragmas, ensureSchema } from './storage';
test('lexical rollup worker backfills without using the tracker connection', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollup-runtime-'));
const dbPath = path.join(directory, 'immersion.sqlite');
const runtime = new LexicalRollupWorkerRuntime();
const db = new Database(dbPath);
try {
applyPragmas(db);
ensureSchema(db);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES ('鳥', '鳥', 'とり', 1700000000, 1700000000, 1)`,
).run();
db.exec('DELETE FROM imm_lexical_daily_rollups');
db.prepare(`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = ?`).run(
'lexical_daily_rollups_ready',
);
db.close();
await runtime.run(dbPath);
const checkDb = new Database(dbPath);
try {
assert.equal(areLexicalDailyRollupsReady(checkDb), true);
} finally {
checkDb.close();
}
} finally {
runtime.destroy();
try {
db.close();
} catch {
// Closed before the worker starts.
}
fs.rmSync(directory, { recursive: true, force: true });
}
});
test('lexical rollup worker module resolves in the current layout', () => {
const workerPath = resolveLexicalRollupWorkerPath();
assert.ok(workerPath, 'expected the lexical rollup worker module to resolve');
assert.ok(workerPath.endsWith(__filename.endsWith('.ts') ? '.ts' : '.js'));
});
test('lexical rollup worker leaves a backfill pending when no worker can start', async () => {
const runtime = new LexicalRollupWorkerRuntime({
resolveWorkerPath: () => null,
warn: () => {},
} as never);
try {
await assert.doesNotReject(runtime.run('/tmp/not-used.sqlite'));
} finally {
runtime.destroy();
}
});
test('lexical rollup worker absorbs termination failures after settling', async () => {
let sendMessage: ((message: { ok: boolean }) => void) | null = null;
const runtime = new LexicalRollupWorkerRuntime({
resolveWorkerPath: () => '/tmp/fake-worker.js',
createWorker: async () => ({
once(event: string, listener: (value: never) => void) {
if (event === 'message') sendMessage = listener as (message: { ok: boolean }) => void;
return this;
},
terminate: async () => {
throw new Error('termination failed');
},
}),
warn: () => {},
} as never);
const unhandled: unknown[] = [];
const captureUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', captureUnhandled);
try {
const task = runtime.run('/tmp/not-used.sqlite');
await new Promise((resolve) => setImmediate(resolve));
const notify = sendMessage as ((message: { ok: boolean }) => void) | null;
assert.ok(notify);
notify({ ok: true });
await task;
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(unhandled, []);
} finally {
process.off('unhandledRejection', captureUnhandled);
runtime.destroy();
}
});
@@ -0,0 +1,109 @@
import fs from 'node:fs';
import path from 'node:path';
import { createLogger } from '../../../logger';
interface WorkerResponse {
ok?: boolean;
error?: unknown;
}
interface WorkerHandle {
once(event: 'message', listener: (message: WorkerResponse) => void): this;
once(event: 'error', listener: (error: Error) => void): this;
once(event: 'exit', listener: (code: number) => void): this;
terminate(): Promise<number>;
}
interface LexicalRollupWorkerRuntimeOptions {
resolveWorkerPath?: () => string | null;
createWorker?: (workerPath: string, workerData: { dbPath: string }) => Promise<WorkerHandle>;
warn?: (message: string, ...meta: unknown[]) => void;
}
const logger = createLogger('main:immersion-tracker:lexical-rollup-worker');
export function resolveLexicalRollupWorkerPath(): string | null {
const fileName = __filename.endsWith('.ts')
? 'lexical-rollup-worker-thread.ts'
: 'lexical-rollup-worker-thread.js';
const workerPath = path.join(__dirname, fileName);
return fs.existsSync(workerPath) ? workerPath : null;
}
export class LexicalRollupWorkerRuntime {
private readonly activeWorkers = new Set<WorkerHandle>();
private destroyed = false;
constructor(private readonly options: LexicalRollupWorkerRuntimeOptions = {}) {}
async run(dbPath: string): Promise<void> {
if (this.destroyed) throw new Error('Lexical rollup worker is shut down');
let worker: WorkerHandle;
try {
const workerPath = (this.options.resolveWorkerPath ?? resolveLexicalRollupWorkerPath)();
if (!workerPath) throw new Error('Emitted lexical rollup worker module was not found');
const createWorker =
this.options.createWorker ??
(async (resolvedPath, workerData) => {
const { Worker } = await import('node:worker_threads');
return new Worker(resolvedPath, { workerData });
});
worker = await createWorker(workerPath, { dbPath });
} catch (error) {
if (this.destroyed) throw new Error('Lexical rollup worker is shut down');
(this.options.warn ?? logger.warn)(
'Lexical rollup worker unavailable; leaving backfill pending for a later startup',
error,
);
return;
}
if (this.destroyed) {
await worker.terminate().catch(() => undefined);
throw new Error('Lexical rollup worker is shut down');
}
return new Promise<void>((resolve, reject) => {
let settled = false;
this.activeWorkers.add(worker);
const settle = (error?: Error) => {
if (settled) return;
settled = true;
this.activeWorkers.delete(worker);
void worker.terminate().catch(() => undefined);
if (error) reject(error);
else resolve();
};
worker.once('message', (message) => {
if (message.ok) settle();
else
settle(
new Error(
`Lexical rollup backfill failed: ${String(message.error ?? 'unknown error')}`,
),
);
});
worker.once('error', (error) => settle(error));
worker.once('exit', (code) => {
if (!settled) {
settle(
new Error(
code === 0
? 'Lexical rollup worker exited without a response'
: `Lexical rollup worker exited with code ${code}`,
),
);
}
});
});
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
for (const worker of this.activeWorkers) {
void worker.terminate().catch(() => undefined);
}
this.activeWorkers.clear();
}
}
@@ -0,0 +1,11 @@
import { parentPort, workerData } from 'node:worker_threads';
import { executeLexicalRollupBackfillTask } from './lexical-rollup-worker';
if (!parentPort) throw new Error('lexical rollup worker missing parent port');
try {
executeLexicalRollupBackfillTask((workerData as { dbPath: string }).dbPath);
parentPort.postMessage({ ok: true });
} catch (error) {
parentPort.postMessage({ error: error instanceof Error ? error.message : String(error) });
}
@@ -0,0 +1,35 @@
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 { areLexicalDailyRollupsReady, getLexicalDailyRollups } from './lexical-rollups';
import { executeLexicalRollupBackfillTask } from './lexical-rollup-worker';
import { Database } from './sqlite';
import { ensureSchema } from './storage';
test('lexical rollup backfill materializes pre-existing vocabulary off the caller DB connection', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollup-worker-'));
const dbPath = path.join(directory, 'immersion.sqlite');
const db = new Database(dbPath);
try {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, ?, ?, ?, 1)`,
).run('犬', '犬', 'いぬ', 1_700_000_000, 1_700_000_000);
db.exec('DELETE FROM imm_lexical_daily_rollups');
db.prepare(`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = ?`).run(
'lexical_daily_rollups_ready',
);
executeLexicalRollupBackfillTask(dbPath);
assert.equal(areLexicalDailyRollupsReady(db), true);
assert.equal(getLexicalDailyRollups(db)[0]?.wordCount, 1);
} finally {
db.close();
fs.rmSync(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,15 @@
import { areLexicalDailyRollupsReady, rebuildLexicalDailyRollups } from './lexical-rollups';
import { Database } from './sqlite';
import { applyPragmas } from './storage';
export function executeLexicalRollupBackfillTask(dbPath: string): void {
const db = new Database(dbPath);
try {
applyPragmas(db);
if (!areLexicalDailyRollupsReady(db)) {
rebuildLexicalDailyRollups(db);
}
} finally {
db.close();
}
}
@@ -0,0 +1,173 @@
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 { getLexicalDailyRollups, rebuildLexicalDailyRollups } from './lexical-rollups';
import { getTrendsDashboard } from './query-trends';
import { getVocabularyChartData, replaceStatsExcludedWords } from './query-lexical';
import { Database } from './sqlite';
import type { DatabaseSync } from './sqlite';
import { ensureSchema } from './storage';
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
return path.join(dir, 'immersion.sqlite');
}
test('lexical daily rollups follow first-seen corrections and deletions', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const firstDay = 19_500;
const correctedDay = firstDay + 2;
const firstSeen = firstDay * 86_400 + 43_200;
const correctedSeen = correctedDay * 86_400 + 43_200;
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, ?, ?, ?, 1)`,
).run('猫', '猫', 'ねこ', firstSeen, firstSeen);
db.prepare(
`INSERT INTO imm_kanji(kanji, first_seen, last_seen, frequency)
VALUES (?, ?, ?, 1)`,
).run('猫', firstSeen, firstSeen);
assert.deepEqual(getLexicalDailyRollups(db), [
{ epochDay: firstDay, wordCount: 1, wordCountWithoutNames: 1, kanjiCount: 1 },
]);
db.prepare(`UPDATE imm_words SET first_seen = ? WHERE headword = ?`).run(correctedSeen, '猫');
db.prepare(`DELETE FROM imm_kanji WHERE kanji = ?`).run('猫');
assert.deepEqual(getLexicalDailyRollups(db), [
{ epochDay: correctedDay, wordCount: 1, wordCountWithoutNames: 1, kanjiCount: 0 },
]);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('vocabulary charts use complete top-word and lexical rollup data', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const insertWord = db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES (?, ?, '', 1700000000, 1700000000, ?)`,
);
for (let index = 0; index < 501; index += 1) {
insertWord.run(`${index}`, `${index}`, index === 500 ? 10_000 : 1);
}
const charts = getVocabularyChartData(db);
assert.equal(charts.topWords[0]?.headword, '語500');
assert.equal(charts.topWords[0]?.frequency, 10_000);
assert.equal(charts.newWordsTimeline[0]?.wordCount, 501);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('vocabulary charts find full top-word sets beyond excluded and name rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const insertWord = db.prepare(
`INSERT INTO imm_words(headword, word, reading, pos2, first_seen, last_seen, frequency)
VALUES (?, ?, '', ?, 1700000000, 1700000000, ?)`,
);
const exclusions = [];
for (let index = 0; index < 100; index += 1) {
const headword = `${index}`;
insertWord.run(
headword,
headword,
index < 80 && index >= 60 ? '固有名詞' : '一般',
100 - index,
);
if (index < 60) exclusions.push({ headword, word: headword, reading: '' });
}
replaceStatsExcludedWords(db, exclusions);
const charts = getVocabularyChartData(db);
assert.equal(charts.topWords.length, 12);
assert.equal(charts.topWords[0]?.headword, '語60');
assert.equal(charts.topWordsWithoutNames.length, 12);
assert.equal(charts.topWordsWithoutNames[0]?.headword, '語80');
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('vocabulary charts handle exclusion lists above one SQLite variable batch', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES ('語0', '語0', '', 1700000000, 1700000000, 1)`,
).run();
const exclusions = Array.from({ length: 10_923 }, (_, index) => ({
headword: `${index}`,
word: `${index}`,
reading: '',
}));
replaceStatsExcludedWords(db, exclusions);
const charts = getVocabularyChartData(db);
assert.deepEqual(charts.topWords, []);
assert.deepEqual(charts.newWordsTimeline, []);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
test('lexical rollup rebuild preserves the original error when rollback also fails', () => {
const originalError = new Error('rebuild failed');
const db = {
exec(sql: string) {
if (sql === 'BEGIN IMMEDIATE') return;
if (sql === 'ROLLBACK') throw new Error('rollback failed');
throw originalError;
},
} as unknown as DatabaseSync;
assert.throws(() => rebuildLexicalDailyRollups(db), originalError);
});
test('trends read historical new-word buckets from lexical rollups', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
VALUES ('海', '海', 'うみ', 1700000000, 1700000000, 1)`,
).run();
db.prepare(`UPDATE imm_lexical_daily_rollups SET word_count = 9`).run();
const dashboard = getTrendsDashboard(db, 'all', 'day', false);
assert.equal(dashboard.progress.newWords[0]?.value, 9);
} finally {
db.close();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
}
});
@@ -0,0 +1,178 @@
import type { DatabaseSync } from './sqlite';
export interface LexicalDailyRollup {
epochDay: number;
wordCount: number;
wordCountWithoutNames: number;
kanjiCount: number;
}
const LOCAL_EPOCH_DAY_SQL = `
CAST(julianday(CAST(%VALUE% AS REAL), 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)
`;
export function localEpochDaySql(value: string): string {
return LOCAL_EPOCH_DAY_SQL.replace('%VALUE%', value);
}
function createWordRollupTriggers(db: DatabaseSync): void {
const dayForNew = localEpochDaySql('NEW.first_seen');
const dayForOld = localEpochDaySql('OLD.first_seen');
db.exec(`
CREATE TRIGGER IF NOT EXISTS imm_words_lexical_rollup_insert
AFTER INSERT ON imm_words
WHEN NEW.first_seen IS NOT NULL
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
VALUES (${dayForNew}, 1, CASE WHEN NEW.pos2 = '固有名詞' THEN 0 ELSE 1 END, 0)
ON CONFLICT(epoch_day) DO UPDATE SET
word_count = word_count + 1,
word_count_without_names = word_count_without_names + excluded.word_count_without_names;
END;
CREATE TRIGGER IF NOT EXISTS imm_words_lexical_rollup_delete
AFTER DELETE ON imm_words
WHEN OLD.first_seen IS NOT NULL
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
VALUES (${dayForOld}, -1, CASE WHEN OLD.pos2 = '固有名詞' THEN 0 ELSE -1 END, 0)
ON CONFLICT(epoch_day) DO UPDATE SET
word_count = word_count - 1,
word_count_without_names = word_count_without_names + excluded.word_count_without_names;
DELETE FROM imm_lexical_daily_rollups
WHERE epoch_day = ${dayForOld} AND word_count = 0 AND kanji_count = 0;
END;
CREATE TRIGGER IF NOT EXISTS imm_words_lexical_rollup_first_seen_update
AFTER UPDATE OF first_seen, pos2 ON imm_words
WHEN OLD.first_seen IS NOT NEW.first_seen OR OLD.pos2 IS NOT NEW.pos2
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${dayForOld}, -1, CASE WHEN OLD.pos2 = '固有名詞' THEN 0 ELSE -1 END, 0
WHERE OLD.first_seen IS NOT NULL
ON CONFLICT(epoch_day) DO UPDATE SET
word_count = word_count - 1,
word_count_without_names = word_count_without_names + excluded.word_count_without_names;
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${dayForNew}, 1, CASE WHEN NEW.pos2 = '固有名詞' THEN 0 ELSE 1 END, 0
WHERE NEW.first_seen IS NOT NULL
ON CONFLICT(epoch_day) DO UPDATE SET
word_count = word_count + 1,
word_count_without_names = word_count_without_names + excluded.word_count_without_names;
DELETE FROM imm_lexical_daily_rollups
WHERE word_count = 0 AND kanji_count = 0;
END;
`);
}
function createKanjiRollupTriggers(db: DatabaseSync): void {
const dayForNew = localEpochDaySql('NEW.first_seen');
const dayForOld = localEpochDaySql('OLD.first_seen');
db.exec(`
CREATE TRIGGER IF NOT EXISTS imm_kanji_lexical_rollup_insert
AFTER INSERT ON imm_kanji WHEN NEW.first_seen IS NOT NULL
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
VALUES (${dayForNew}, 0, 0, 1)
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count + 1;
END;
CREATE TRIGGER IF NOT EXISTS imm_kanji_lexical_rollup_delete
AFTER DELETE ON imm_kanji WHEN OLD.first_seen IS NOT NULL
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
VALUES (${dayForOld}, 0, 0, -1)
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count - 1;
DELETE FROM imm_lexical_daily_rollups
WHERE epoch_day = ${dayForOld} AND word_count = 0 AND kanji_count = 0;
END;
CREATE TRIGGER IF NOT EXISTS imm_kanji_lexical_rollup_first_seen_update
AFTER UPDATE OF first_seen ON imm_kanji WHEN OLD.first_seen IS NOT NEW.first_seen
BEGIN
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${dayForOld}, 0, 0, -1 WHERE OLD.first_seen IS NOT NULL
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count - 1;
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${dayForNew}, 0, 0, 1 WHERE NEW.first_seen IS NOT NULL
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count + 1;
DELETE FROM imm_lexical_daily_rollups WHERE word_count = 0 AND kanji_count = 0;
END;
`);
}
export function ensureLexicalDailyRollupTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_lexical_daily_rollups(
epoch_day INTEGER PRIMARY KEY,
word_count INTEGER NOT NULL DEFAULT 0,
word_count_without_names INTEGER NOT NULL DEFAULT 0,
kanji_count INTEGER NOT NULL DEFAULT 0
);
INSERT INTO imm_rollup_state(state_key, state_value)
VALUES ('lexical_daily_rollups_ready', '0')
ON CONFLICT(state_key) DO NOTHING;
`);
createWordRollupTriggers(db);
createKanjiRollupTriggers(db);
}
export function areLexicalDailyRollupsReady(db: DatabaseSync): boolean {
const row = db
.prepare(`SELECT state_value AS value FROM imm_rollup_state WHERE state_key = ?`)
.get('lexical_daily_rollups_ready') as { value: string } | null;
return row?.value === '1';
}
export function markLexicalDailyRollupsReady(db: DatabaseSync): void {
db.prepare(`UPDATE imm_rollup_state SET state_value = '1' WHERE state_key = ?`).run(
'lexical_daily_rollups_ready',
);
}
/** Rebuild from the first-seen source of truth; run off the UI/main DB thread. */
export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
let transactionStarted = false;
try {
db.exec('BEGIN IMMEDIATE');
transactionStarted = true;
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)
SELECT ${localEpochDaySql('first_seen')}, COUNT(*),
SUM(CASE WHEN pos2 = '固有名詞' THEN 0 ELSE 1 END), 0
FROM imm_words
WHERE first_seen IS NOT NULL
GROUP BY ${localEpochDaySql('first_seen')};
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
SELECT ${localEpochDaySql('first_seen')}, 0, 0, COUNT(*)
FROM imm_kanji
WHERE first_seen IS NOT NULL
GROUP BY ${localEpochDaySql('first_seen')}
ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count + excluded.kanji_count;
`);
markLexicalDailyRollupsReady(db);
db.exec('COMMIT');
} catch (error) {
if (transactionStarted) {
try {
db.exec('ROLLBACK');
} catch {
// Preserve the rebuild failure; it is the actionable cause.
}
}
throw error;
}
}
export function getLexicalDailyRollups(db: DatabaseSync): LexicalDailyRollup[] {
return db
.prepare(
`
SELECT epoch_day AS epochDay, word_count AS wordCount,
word_count_without_names AS wordCountWithoutNames, kanji_count AS kanjiCount
FROM imm_lexical_daily_rollups
ORDER BY epoch_day ASC
`,
)
.all() as LexicalDailyRollup[];
}
@@ -13,19 +13,36 @@ import type {
SimilarWordRow, SimilarWordRow,
StatsExcludedWordRow, StatsExcludedWordRow,
VocabularyStatsRow, VocabularyStatsRow,
VocabularyStatsSummary,
WordAnimeAppearanceRow, WordAnimeAppearanceRow,
WordDetailRow, WordDetailRow,
WordOccurrenceRow, WordOccurrenceRow,
} from './types'; } from './types';
import { fromDbTimestamp, toDbTimestamp } from './query-shared'; import { fromDbTimestamp, toDbTimestamp } from './query-shared';
import { nowMs } from './time'; import { nowMs } from './time';
import {
areLexicalDailyRollupsReady,
getLexicalDailyRollups,
localEpochDaySql,
} from './lexical-rollups';
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4; const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100; const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
const VOCABULARY_CHART_LIMIT = 12;
const VOCABULARY_CHART_PAGE_SIZE = 100;
const EXCLUSION_ALIAS_BATCH_SIZE = 300;
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50; const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
const SENTENCE_SEARCH_MAX_LIMIT = 100; const SENTENCE_SEARCH_MAX_LIMIT = 100;
const KANJI_PATTERN = /\p{Script=Han}/gu; const KANJI_PATTERN = /\p{Script=Han}/gu;
export interface VocabularyChartData {
ready: boolean;
topWords: Array<{ wordId: number; headword: string; frequency: number }>;
topWordsWithoutNames: Array<{ wordId: number; headword: string; frequency: number }>;
newWordsTimeline: Array<{ epochDay: number; wordCount: number }>;
newWordsTimelineWithoutNames: Array<{ epochDay: number; wordCount: number }>;
}
function resolveSentenceSearchLimit(limit: number): number { function resolveSentenceSearchLimit(limit: number): number {
if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT; if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT;
const normalized = Math.floor(limit); const normalized = Math.floor(limit);
@@ -153,6 +170,182 @@ export function getVocabularyStats(
return visibleRows.slice(0, limit); return visibleRows.slice(0, limit);
} }
/**
* Chart data is intentionally independent of the paginated vocabulary tables.
* Top words use the frequency index; new-word history reads permanent daily
* lexical rollups rather than loading every vocabulary row into the dashboard.
*/
export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData {
const ready = areLexicalDailyRollupsReady(db);
const excludedAliases = new Set(
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
);
const isExcluded = (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>): boolean =>
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias));
const topWords = getTopVocabularyChartWords(db, isExcluded);
const rollups = ready ? getLexicalDailyRollups(db) : [];
const timeline = new Map(rollups.map((row) => [row.epochDay, { ...row }]));
if (excludedAliases.size > 0 && ready) {
const aliases = [...excludedAliases];
const excludedRows = new Map<
number,
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
wordId: number;
epochDay: number;
}
>();
for (let offset = 0; offset < aliases.length; offset += EXCLUSION_ALIAS_BATCH_SIZE) {
const batch = aliases.slice(offset, offset + EXCLUSION_ALIAS_BATCH_SIZE);
const placeholders = batch.map(() => '?').join(', ');
const rows = db
.prepare(
`
SELECT id AS wordId, headword, word, reading, pos2,
${localEpochDaySql('first_seen')} AS epochDay
FROM imm_words
WHERE headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders})
`,
)
.all(...batch, ...batch, ...batch) as Array<
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
wordId: number;
epochDay: number;
}
>;
for (const row of rows) excludedRows.set(row.wordId, row);
}
for (const word of excludedRows.values()) {
if (!isExcluded(word)) continue;
const rollup = timeline.get(word.epochDay);
if (!rollup) continue;
rollup.wordCount -= 1;
if (word.pos2 !== '固有名詞') rollup.wordCountWithoutNames -= 1;
}
}
return {
ready,
topWords: topWords.all.map((word) => ({
wordId: word.wordId,
headword: word.headword,
frequency: word.frequency,
})),
topWordsWithoutNames: topWords.withoutNames.map((word) => ({
wordId: word.wordId,
headword: word.headword,
frequency: word.frequency,
})),
newWordsTimeline: [...timeline.values()]
.filter((row) => row.wordCount > 0)
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCount })),
newWordsTimelineWithoutNames: [...timeline.values()]
.filter((row) => row.wordCountWithoutNames > 0)
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCountWithoutNames })),
};
}
function getTopVocabularyChartWords(
db: DatabaseSync,
isExcluded: (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>) => boolean,
): { all: VocabularyStatsRow[]; withoutNames: VocabularyStatsRow[] } {
const stmt = db.prepare(`
SELECT id AS wordId, headword, word, reading,
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
frequency, frequency_rank AS frequencyRank,
first_seen AS firstSeen, last_seen AS lastSeen,
0 AS animeCount
FROM imm_words
ORDER BY frequency DESC, id
LIMIT ? OFFSET ?
`);
const all: VocabularyStatsRow[] = [];
const withoutNames: VocabularyStatsRow[] = [];
let offset = 0;
while (all.length < VOCABULARY_CHART_LIMIT || withoutNames.length < VOCABULARY_CHART_LIMIT) {
const page = stmt.all(VOCABULARY_CHART_PAGE_SIZE, offset) as VocabularyStatsRow[];
if (page.length === 0) break;
for (const word of page) {
if (!isVocabularyStatsRowVisible(word) || isExcluded(word)) continue;
if (all.length < VOCABULARY_CHART_LIMIT) all.push(word);
if (word.pos2 !== '固有名詞' && withoutNames.length < VOCABULARY_CHART_LIMIT) {
withoutNames.push(word);
}
}
offset += page.length;
}
return { all, withoutNames };
}
function excludedVocabularyAliases(
word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>,
): string[] {
const aliases = [word.headword.trim(), word.word.trim()].filter(Boolean);
if (aliases.length === 0) aliases.push(word.reading.trim());
return [...new Set(aliases)];
}
function timestampSeconds(timestamp: number): number {
return timestamp < 10_000_000_000 ? timestamp : Math.floor(timestamp / 1000);
}
export function getVocabularySummary(
db: DatabaseSync,
knownWords: ReadonlySet<string> | null,
nowMs: number = Date.now(),
): VocabularyStatsSummary {
const words = db
.prepare(
`
SELECT id AS wordId, headword, word, reading,
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
frequency, frequency_rank AS frequencyRank,
first_seen AS firstSeen, last_seen AS lastSeen,
0 AS animeCount
FROM imm_words
`,
)
.all() as VocabularyStatsRow[];
const excludedAliases = new Set(
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
);
const weekAgoSec = nowMs / 1000 - 7 * 86_400;
const summary: VocabularyStatsSummary = {
uniqueWords: 0,
uniqueWordsWithoutNames: 0,
uniqueKanji: (db.prepare('SELECT COUNT(*) AS count FROM imm_kanji').get() as { count: number })
.count,
newThisWeek: 0,
newThisWeekWithoutNames: 0,
knownWordCount: knownWords ? 0 : null,
knownWordCountWithoutNames: knownWords ? 0 : null,
};
for (const word of words) {
if (
!isVocabularyStatsRowVisible(word) ||
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias))
) {
continue;
}
const isName = word.pos2 === '固有名詞';
const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec;
const isKnown = knownWords?.has(word.headword) ?? false;
summary.uniqueWords += 1;
if (!isName) summary.uniqueWordsWithoutNames += 1;
if (isNewThisWeek) {
summary.newThisWeek += 1;
if (!isName) summary.newThisWeekWithoutNames += 1;
}
if (isKnown) {
summary.knownWordCount! += 1;
if (!isName) summary.knownWordCountWithoutNames! += 1;
}
}
return summary;
}
export function getStatsExcludedWords(db: DatabaseSync): StatsExcludedWordRow[] { export function getStatsExcludedWords(db: DatabaseSync): StatsExcludedWordRow[] {
return db return db
.prepare( .prepare(
@@ -13,6 +13,7 @@ import {
toDbTimestamp, toDbTimestamp,
} from './query-shared'; } from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions'; import { getDailyRollups, getMonthlyRollups } from './query-sessions';
import { areLexicalDailyRollupsReady, getLexicalDailyRollups } from './lexical-rollups';
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all'; type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
type TrendGroupBy = 'day' | 'month'; type TrendGroupBy = 'day' | 'month';
@@ -660,6 +661,16 @@ function buildNewWordsPerDay(
cutoffMs: string | null, cutoffMs: string | null,
axis: number[] | null, axis: number[] | null,
): TrendChartPoint[] { ): TrendChartPoint[] {
if (areLexicalDailyRollupsReady(db)) {
// A trend range is defined in calendar buckets, so the rollup includes the
// complete local cutoff day rather than applying a time-of-day boundary.
const cutoffDay = cutoffMs === null ? null : getLocalEpochDay(db, cutoffMs);
const rows = getLexicalDailyRollups(db).filter(
(row) => cutoffDay === null || row.epochDay >= cutoffDay,
);
return fillAxisPoints(axis, new Map(rows.map((row) => [row.epochDay, row.wordCount])));
}
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(` const prepared = db.prepare(`
SELECT SELECT
@@ -691,6 +702,18 @@ function buildNewWordsPerMonth(
cutoffMs: string | null, cutoffMs: string | null,
axis: number[] | null, axis: number[] | null,
): TrendChartPoint[] { ): TrendChartPoint[] {
if (areLexicalDailyRollupsReady(db)) {
const cutoffDay = cutoffMs === null ? null : getLocalEpochDay(db, cutoffMs);
const byMonth = new Map<number, number>();
for (const row of getLexicalDailyRollups(db)) {
if (cutoffDay !== null && row.epochDay < cutoffDay) continue;
const { year, month } = dayPartsFromEpochDay(row.epochDay);
const monthKey = year * 100 + month;
byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + row.wordCount);
}
return fillAxisPoints(axis, byMonth);
}
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(` const prepared = db.prepare(`
SELECT SELECT
@@ -4,6 +4,7 @@ import { parseMediaInfo } from '../../../jimaku/utils';
import { normalizeTitleIdentity } from '../../utils/title-normalization'; import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { nowMs } from './time'; import { nowMs } from './time';
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
import { SCHEMA_VERSION } from './types'; import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types'; import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
import { toDbMs, toDbTimestamp } from './query-shared'; import { toDbMs, toDbTimestamp } from './query-shared';
@@ -890,11 +891,11 @@ export function ensureSchema(db: DatabaseSync): void {
VALUES ('last_rollup_sample_ms', 0) VALUES ('last_rollup_sample_ms', 0)
ON CONFLICT(state_key) DO NOTHING ON CONFLICT(state_key) DO NOTHING
`); `);
const currentVersion = db const currentVersion = db
.prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1') .prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1')
.get() as { schema_version: number } | null; .get() as { schema_version: number } | null;
if (currentVersion?.schema_version === SCHEMA_VERSION) { if (currentVersion?.schema_version === SCHEMA_VERSION) {
ensureLexicalDailyRollupTables(db);
ensureLifetimeSummaryTables(db); ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db); ensureStatsExcludedWordsTable(db);
ensureAnimeMergeTables(db); ensureAnimeMergeTables(db);
@@ -1453,6 +1454,7 @@ export function ensureSchema(db: DatabaseSync): void {
migrateSessionEventTimestampsToText(db); migrateSessionEventTimestampsToText(db);
ensureLexicalDailyRollupTables(db);
ensureLifetimeSummaryTables(db); ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db); ensureStatsExcludedWordsTable(db);
@@ -1585,6 +1587,12 @@ export function ensureSchema(db: DatabaseSync): void {
VALUES (${SCHEMA_VERSION}, ${toDbTimestamp(nowMs())}) VALUES (${SCHEMA_VERSION}, ${toDbTimestamp(nowMs())})
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
`); `);
// A new database has no history to materialize. Upgrades are populated by the
// background worker so startup never scans the existing vocabulary table.
if (!currentVersion) {
markLexicalDailyRollupsReady(db);
}
} }
export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPreparedStatements { export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPreparedStatements {
+11 -1
View File
@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 21; export const SCHEMA_VERSION = 22;
export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500; export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -306,6 +306,16 @@ export interface VocabularyStatsRow {
lastSeen: number; lastSeen: number;
} }
export interface VocabularyStatsSummary {
uniqueWords: number;
uniqueWordsWithoutNames: number;
uniqueKanji: number;
newThisWeek: number;
newThisWeekWithoutNames: number;
knownWordCount: number | null;
knownWordCountWithoutNames: number | null;
}
export interface StatsExcludedWordRow { export interface StatsExcludedWordRow {
headword: string; headword: string;
word: string; word: string;
@@ -0,0 +1,67 @@
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 {
resolveVocabularySummaryWorkerPath,
VocabularySummaryWorkerRuntime,
} from './vocabulary-summary-worker-runtime';
import { Database } from './sqlite';
import { applyPragmas, ensureSchema } from './storage';
test('vocabulary summary worker reads the database from a separate connection', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-vocabulary-summary-worker-'));
const dbPath = path.join(tempDir, 'immersion.sqlite');
const runtime = new VocabularySummaryWorkerRuntime();
const db = new Database(dbPath);
try {
applyPragmas(db);
ensureSchema(db);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3,
first_seen, last_seen, frequency
) VALUES ('猫', '猫', 'ねこ', 'noun', '名詞', '一般', '', 1, 1, 1)
`,
).run();
db.close();
const summary = await runtime.run(dbPath, new Set(['猫']));
assert.equal(summary.uniqueWords, 1);
assert.equal(summary.knownWordCount, 1);
} finally {
runtime.destroy();
try {
db.close();
} catch {
// The worker needs the setup connection closed before it starts.
}
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('vocabulary summary worker module resolves in the current layout', () => {
const workerPath = resolveVocabularySummaryWorkerPath();
assert.ok(workerPath, 'expected the vocabulary summary worker module to resolve');
assert.ok(workerPath.endsWith(__filename.endsWith('.ts') ? '.ts' : '.js'));
});
test('vocabulary summary worker never falls back to the caller thread', async () => {
const runtime = new VocabularySummaryWorkerRuntime({
resolveWorkerPath: () => null,
warn: () => {},
});
try {
await assert.rejects(
runtime.run('/tmp/subminer-summary-worker-not-used.sqlite', null),
/worker unavailable/i,
);
} finally {
runtime.destroy();
}
});
@@ -0,0 +1,125 @@
import fs from 'node:fs';
import path from 'node:path';
import { createLogger } from '../../../logger';
import type { VocabularyStatsSummary } from './types';
interface VocabularySummaryWorkerResponse {
summary?: VocabularyStatsSummary;
error?: unknown;
}
interface VocabularySummaryWorkerHandle {
once(event: 'message', listener: (message: VocabularySummaryWorkerResponse) => void): this;
once(event: 'error', listener: (error: Error) => void): this;
once(event: 'exit', listener: (code: number) => void): this;
terminate(): Promise<number>;
}
interface VocabularySummaryWorkerRuntimeOptions {
resolveWorkerPath?: () => string | null;
createWorker?: (
workerPath: string,
workerData: { dbPath: string; knownWords: string[] | null },
) => Promise<VocabularySummaryWorkerHandle>;
warn?: (message: string, ...meta: unknown[]) => void;
}
export type RunVocabularySummaryTask = (
dbPath: string,
knownWords: ReadonlySet<string> | null,
) => Promise<VocabularyStatsSummary>;
export function resolveVocabularySummaryWorkerPath(): string | null {
const fileName = __filename.endsWith('.ts')
? 'vocabulary-summary-worker-thread.ts'
: 'vocabulary-summary-worker-thread.js';
const workerPath = path.join(__dirname, fileName);
return fs.existsSync(workerPath) ? workerPath : null;
}
const logger = createLogger('main:immersion-tracker:vocabulary-summary-worker');
export class VocabularySummaryWorkerRuntime {
private readonly activeWorkers = new Set<VocabularySummaryWorkerHandle>();
private destroyed = false;
constructor(private readonly options: VocabularySummaryWorkerRuntimeOptions = {}) {}
async run(
dbPath: string,
knownWords: ReadonlySet<string> | null,
): Promise<VocabularyStatsSummary> {
if (this.destroyed) throw new Error('Vocabulary summary worker is shut down');
const workerData = { dbPath, knownWords: knownWords ? [...knownWords] : null };
let worker: VocabularySummaryWorkerHandle;
try {
const workerPath = (this.options.resolveWorkerPath ?? resolveVocabularySummaryWorkerPath)();
if (!workerPath) throw new Error('Emitted vocabulary summary worker module was not found');
const createWorker =
this.options.createWorker ??
(async (resolvedPath, data) => {
const { Worker } = await import('node:worker_threads');
return new Worker(resolvedPath, { workerData: data });
});
worker = await createWorker(workerPath, workerData);
} catch (error) {
if (this.destroyed) throw new Error('Vocabulary summary worker is shut down');
(this.options.warn ?? logger.warn)(
'Vocabulary summary worker unavailable; refusing to scan vocabulary on the current thread',
error,
);
throw new Error('Vocabulary summary worker unavailable');
}
if (this.destroyed) {
await worker.terminate().catch(() => undefined);
throw new Error('Vocabulary summary worker is shut down');
}
return new Promise<VocabularyStatsSummary>((resolve, reject) => {
let settled = false;
this.activeWorkers.add(worker);
const settle = (result: VocabularyStatsSummary | Error) => {
if (settled) return;
settled = true;
this.activeWorkers.delete(worker);
void worker.terminate().catch(() => undefined);
if (result instanceof Error) reject(result);
else resolve(result);
};
worker.once('message', (message) => {
if (message.summary) {
settle(message.summary);
return;
}
settle(
new Error(
`Vocabulary summary failed: ${String(message.error ?? 'unknown worker error')}`,
),
);
});
worker.once('error', (error) => settle(error));
worker.once('exit', (code) => {
if (!settled) {
settle(
new Error(
code === 0
? 'Vocabulary summary worker exited without a response'
: `Vocabulary summary worker exited with code ${code}`,
),
);
}
});
});
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
for (const worker of this.activeWorkers) {
void worker.terminate().catch(() => undefined);
}
this.activeWorkers.clear();
}
}
@@ -0,0 +1,19 @@
import { parentPort, workerData } from 'node:worker_threads';
import { executeVocabularySummaryTask } from './vocabulary-summary-worker';
interface VocabularySummaryWorkerData {
dbPath: string;
knownWords: string[] | null;
}
if (!parentPort) throw new Error('vocabulary summary worker missing parent port');
const request = workerData as VocabularySummaryWorkerData;
try {
parentPort.postMessage({
summary: executeVocabularySummaryTask(request.dbPath, request.knownWords),
});
} catch (error) {
parentPort.postMessage({ error: error instanceof Error ? error.message : String(error) });
}
@@ -0,0 +1,17 @@
import { getVocabularySummary } from './query-lexical';
import { Database } from './sqlite';
import { applyPragmas } from './storage';
import type { VocabularyStatsSummary } from './types';
export function executeVocabularySummaryTask(
dbPath: string,
knownWords: string[] | null,
): VocabularyStatsSummary {
const db = new Database(dbPath);
try {
applyPragmas(db);
return getVocabularySummary(db, knownWords ? new Set(knownWords) : null);
} finally {
db.close();
}
}
+1 -8
View File
@@ -34,7 +34,6 @@ import {
parseSubsyncManualRunRequest, parseSubsyncManualRunRequest,
parseYoutubePickerResolveRequest, parseYoutubePickerResolveRequest,
} from '../../shared/ipc/validators'; } from '../../shared/ipc/validators';
import { applyOverlayClickThrough } from './overlay-click-through';
const { ipcMain } = electron; const { ipcMain } = electron;
@@ -443,13 +442,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
const senderWindow = const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
if (senderWindow && !senderWindow.isDestroyed()) { if (senderWindow && !senderWindow.isDestroyed()) {
// Route forwarding requests through the platform-aware helper so Windows never senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
// installs Electron's global mouse hook (see overlay-click-through.ts).
if (ignore && parsedOptions?.forward) {
applyOverlayClickThrough(senderWindow);
} else {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
} }
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow); deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
}, },
+2 -2
View File
@@ -53,7 +53,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'sub-scale-by-window', 'sub-scale-by-window',
'osd-height', 'osd-height',
'osd-dimensions', 'osd-dimensions',
'sub-text/ass', 'sub-text-ass',
'sub-border-size', 'sub-border-size',
'sub-shadow-offset', 'sub-shadow-offset',
'sub-ass-override', 'sub-ass-override',
@@ -74,7 +74,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
request_id: MPV_REQUEST_ID_SUBTEXT, request_id: MPV_REQUEST_ID_SUBTEXT,
}, },
{ {
command: ['get_property', 'sub-text/ass'], command: ['get_property', 'sub-text-ass'],
request_id: MPV_REQUEST_ID_SUBTEXT_ASS, request_id: MPV_REQUEST_ID_SUBTEXT_ASS,
}, },
{ {
-22
View File
@@ -129,28 +129,6 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
}); });
test('dispatchMpvProtocolMessage emits ASS subtitle text from the current mpv property', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-text/ass', data: '{\\b1}字幕' },
deps,
);
assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]);
});
test('dispatchMpvProtocolMessage emits ASS subtitle text from the legacy mpv property', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-text-ass', data: '{\\b1}字幕' },
deps,
);
assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]);
});
test('dispatchMpvProtocolMessage emits subtitle track changes', async () => { test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
const { deps, state } = createDeps({ const { deps, state } = createDeps({
emitSubtitleTrackChange: (payload) => state.events.push(payload), emitSubtitleTrackChange: (payload) => state.events.push(payload),
+1 -1
View File
@@ -248,7 +248,7 @@ export async function dispatchMpvProtocolMessage(
isOverlayVisible: overlayVisible, isOverlayVisible: overlayVisible,
}); });
deps.setCurrentSubText(nextSubText); deps.setCurrentSubText(nextSubText);
} else if (msg.name === 'sub-text/ass' || msg.name === 'sub-text-ass') { } else if (msg.name === 'sub-text-ass') {
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' }); deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
} else if (msg.name === 'sub-start') { } else if (msg.name === 'sub-start') {
deps.setCurrentSubStart((msg.data as number) || 0); deps.setCurrentSubStart((msg.data as number) || 0);
-13
View File
@@ -505,17 +505,6 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
(command as { command: unknown[] }).command[1] === 1 && (command as { command: unknown[] }).command[1] === 1 &&
(command as { command: unknown[] }).command[2] === 'sub-text', (command as { command: unknown[] }).command[2] === 'sub-text',
); );
const hasAssSubtitleSubscription = commands.some(
(command) =>
Array.isArray((command as { command: unknown[] }).command) &&
(command as { command: unknown[] }).command[0] === 'observe_property' &&
(command as { command: unknown[] }).command[2] === 'sub-text/ass',
);
const hasDeprecatedAssSubtitleProperty = commands.some(
(command) =>
Array.isArray((command as { command: unknown[] }).command) &&
(command as { command: unknown[] }).command.includes('sub-text-ass'),
);
const hasPathRequest = commands.some( const hasPathRequest = commands.some(
(command) => (command) =>
Array.isArray((command as { command: unknown[] }).command) && Array.isArray((command as { command: unknown[] }).command) &&
@@ -525,8 +514,6 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
assert.equal(hasSecondaryVisibilityReset, true); assert.equal(hasSecondaryVisibilityReset, true);
assert.equal(hasTrackSubscription, true); assert.equal(hasTrackSubscription, true);
assert.equal(hasAssSubtitleSubscription, true);
assert.equal(hasDeprecatedAssSubtitleProperty, false);
assert.equal(hasPathRequest, true); assert.equal(hasPathRequest, true);
}); });
@@ -1,21 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyOverlayClickThrough } from './overlay-click-through';
test('applyOverlayClickThrough requests forwarding only off Windows', () => {
const calls: Array<{ ignore: boolean; forward: boolean }> = [];
const window = {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push({ ignore, forward: options?.forward === true });
},
};
applyOverlayClickThrough(window, true);
applyOverlayClickThrough(window, false);
assert.deepEqual(calls, [
{ ignore: true, forward: false },
{ ignore: true, forward: true },
]);
});
@@ -1,27 +0,0 @@
type ClickThroughWindow = {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
};
/**
* Puts an overlay window into click-through mode. Forwarded mouse-move ({ forward: true }) is
* what lets renderer hover tracking wake a click-through overlay, but on Windows Electron
* implements it with a global WH_MOUSE_LL hook whose callback runs on the main-process message
* loop, so any main-thread stall delays mouse input system-wide (electron/electron#10183).
* Windows instead wakes the overlay via the main-process cursor poll
* (tickWindowsOverlayPointerInteraction), so no forwarding is requested there. macOS still
* needs forwarding for renderer hover tracking; Linux ignores the flag entirely
* (electron/electron#16777).
*
* Pass isWindowsPlatform when the caller already carries a platform flag (tests simulate
* platforms through it); otherwise the real process.platform decides.
*/
export function applyOverlayClickThrough(
window: ClickThroughWindow,
isWindowsPlatform?: boolean,
): void {
if (isWindowsPlatform ?? process.platform === 'win32') {
window.setIgnoreMouseEvents(true);
} else {
window.setIgnoreMouseEvents(true, { forward: true });
}
}
+7 -7
View File
@@ -848,7 +848,7 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked
} as never); } as never);
assert.ok(calls.includes('opacity:0')); assert.ok(calls.includes('opacity:0'));
assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive')); assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('sync-windows-z-order')); assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top')); assert.ok(!calls.includes('move-top'));
@@ -1060,7 +1060,7 @@ test('tracked Windows overlay refresh rebinds while already visible', () => {
isWindowsPlatform: true, isWindowsPlatform: true,
} as never); } as never);
assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order')); assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top')); assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
@@ -1134,7 +1134,7 @@ test('forced passthrough still reapplies while visible on Windows', () => {
forceMousePassthrough: true, forceMousePassthrough: true,
} as never); } as never);
assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(!calls.includes('always-on-top:false')); assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top')); assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('sync-windows-z-order')); assert.ok(calls.includes('sync-windows-z-order'));
@@ -1339,7 +1339,7 @@ test('tracked Windows overlay rebinds without hiding when tracker focus changes'
assert.ok(!calls.includes('always-on-top:false')); assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top')); assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order')); assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level')); assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order')); assert.ok(!calls.includes('enforce-order'));
@@ -1489,7 +1489,7 @@ test('tracked Windows overlay reshows click-through even if focus state is stale
isWindowsPlatform: true, isWindowsPlatform: true,
} as never); } as never);
assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive')); assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
}); });
@@ -1532,7 +1532,7 @@ test('tracked Windows overlay binds above mpv even when tracker focus lags', ()
assert.ok(!calls.includes('always-on-top:false')); assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top')); assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order')); assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level')); assert.ok(!calls.includes('ensure-level'));
}); });
@@ -2193,7 +2193,7 @@ test('Windows preserves visible overlay and rebinds to mpv while tracker transie
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('always-on-top:false')); assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top')); assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order')); assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level')); assert.ok(!calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts')); assert.ok(calls.includes('sync-shortcuts'));
+4 -5
View File
@@ -1,7 +1,6 @@
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import { BaseWindowTracker } from '../../window-trackers'; import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types'; import { WindowGeometry } from '../../types';
import { applyOverlayClickThrough } from './overlay-click-through';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48; const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
@@ -118,7 +117,7 @@ export function updateVisibleOverlayVisibility(args: {
clearPendingWindowsOverlayReveal(mainWindow); clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
} }
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform); mainWindow.setIgnoreMouseEvents(true, { forward: true });
releaseOverlayWindowLevel(mainWindow); releaseOverlayWindowLevel(mainWindow);
mainWindow.hide(); mainWindow.hide();
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
@@ -216,7 +215,7 @@ export function updateVisibleOverlayVisibility(args: {
shouldPreserveWindowsOverlayDuringFocusHandoff || shouldPreserveWindowsOverlayDuringFocusHandoff ||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv'); (hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
if (shouldIgnoreMouseEvents) { if (shouldIgnoreMouseEvents) {
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform); mainWindow.setIgnoreMouseEvents(true, { forward: true });
} else { } else {
mainWindow.setIgnoreMouseEvents(false); mainWindow.setIgnoreMouseEvents(false);
} }
@@ -264,7 +263,7 @@ export function updateVisibleOverlayVisibility(args: {
if (hasNonNativeInputRegion) { if (hasNonNativeInputRegion) {
mainWindow.setIgnoreMouseEvents(false); mainWindow.setIgnoreMouseEvents(false);
} else { } else {
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform); mainWindow.setIgnoreMouseEvents(true, { forward: true });
} }
if (args.isWindowsPlatform) { if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal( scheduleWindowsOverlayReveal(
@@ -425,7 +424,7 @@ export function updateVisibleOverlayVisibility(args: {
return; return;
} }
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform); mainWindow.setIgnoreMouseEvents(true, { forward: true });
releaseOverlayWindowLevel(mainWindow); releaseOverlayWindowLevel(mainWindow);
mainWindow.hide(); mainWindow.hide();
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
@@ -15,32 +15,6 @@ test('overlay window config explicitly disables renderer sandbox for preload com
assert.equal(options.webPreferences?.backgroundThrottling, false); assert.equal(options.webPreferences?.backgroundThrottling, false);
}); });
test('macOS modal overlay uses a fullscreen auxiliary panel without changing the passive overlay', () => {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
platform: 'darwin',
yomitanSession: null,
});
const modalOptions = buildOverlayWindowOptions('modal', {
isDev: false,
platform: 'darwin',
yomitanSession: null,
});
assert.equal(visibleOptions.type, undefined);
assert.equal(modalOptions.type, 'panel');
});
test('non-macOS modal overlay remains a regular window', () => {
const options = buildOverlayWindowOptions('modal', {
isDev: false,
platform: 'linux',
yomitanSession: null,
});
assert.equal(options.type, undefined);
});
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => { test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
@@ -1,2 +1 @@
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady'; export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
export const OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG = '__subminerOverlayDocumentLoaded';
+4 -9
View File
@@ -12,17 +12,15 @@ export function buildOverlayWindowOptions(
options: { options: {
isDev: boolean; isDev: boolean;
linuxX11FullscreenOverlay?: boolean; linuxX11FullscreenOverlay?: boolean;
platform?: NodeJS.Platform;
yomitanSession?: Session | null; yomitanSession?: Session | null;
}, },
): BrowserWindowConstructorOptions { ): BrowserWindowConstructorOptions {
const platform = options.platform ?? process.platform; const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const showNativeDebugFrame = platform === 'win32' && options.isDev; const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible';
const isLinuxVisibleOverlay = platform === 'linux' && kind === 'visible';
const isLinuxFullscreenOverlay = const isLinuxFullscreenOverlay =
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true; isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
const shouldStartAlwaysOnTop = const shouldStartAlwaysOnTop =
!(platform === 'win32' && kind === 'visible') && !(process.platform === 'win32' && kind === 'visible') &&
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay); (!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay; const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
@@ -43,10 +41,7 @@ export function buildOverlayWindowOptions(
hasShadow: false, hasShadow: false,
focusable: !isLinuxFullscreenOverlay, focusable: !isLinuxFullscreenOverlay,
acceptFirstMouse: true, acceptFirstMouse: true,
// A macOS panel is a fullscreen auxiliary window, so modal surfaces stay on the ...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
// active mpv Space instead of opening on SubMiner's last regular desktop.
...(platform === 'darwin' && kind === 'modal' ? { type: 'panel' as const } : {}),
...(platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: { webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'), preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true, contextIsolation: true,
+1 -16
View File
@@ -16,10 +16,7 @@ import {
} from './hyprland-window-placement'; } from './hyprland-window-placement';
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options'; import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds'; import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
import { import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
OVERLAY_WINDOW_CONTENT_READY_FLAG,
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
} from './overlay-window-flags';
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
const logger = createLogger('main:overlay-window'); const logger = createLogger('main:overlay-window');
@@ -136,9 +133,6 @@ export function createOverlayWindow(
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false; ] = false;
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
] = false;
if (!(process.platform === 'win32' && kind === 'visible')) { if (!(process.platform === 'win32' && kind === 'visible')) {
options.ensureOverlayWindowLevel(window); options.ensureOverlayWindowLevel(window);
@@ -150,20 +144,11 @@ export function createOverlayWindow(
}); });
window.webContents.on('did-finish-load', () => { window.webContents.on('did-finish-load', () => {
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
] = true;
window.setTitle(OVERLAY_WINDOW_TITLES[kind]); window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
options.onRuntimeOptionsChanged(); options.onRuntimeOptionsChanged();
options.onWindowDidFinishLoad?.(); options.onWindowDidFinishLoad?.();
}); });
window.webContents.on('did-start-loading', () => {
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
] = false;
});
window.webContents.on('page-title-updated', (event) => { window.webContents.on('page-title-updated', (event) => {
event.preventDefault(); event.preventDefault();
window.setTitle(OVERLAY_WINDOW_TITLES[kind]); window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
@@ -10,6 +10,7 @@ import {
parseExcludedWordsBody, parseExcludedWordsBody,
parseIntQuery, parseIntQuery,
parsePositiveIdList, parsePositiveIdList,
loadKnownWordsSet,
} from './route-support.js'; } from './route-support.js';
export function registerStatsLibraryRoutes( export function registerStatsLibraryRoutes(
@@ -31,6 +32,17 @@ export function registerStatsLibraryRoutes(
return c.json(statsJson('vocabulary', vocab)); return c.json(statsJson('vocabulary', vocab));
}); });
app.get('/api/stats/vocabulary/summary', async (c) => {
const summary = await tracker.getVocabularySummary(
loadKnownWordsSet(options?.knownWordCachePath),
);
return c.json(statsJson('vocabularySummary', summary));
});
app.get('/api/stats/vocabulary/charts', async (c) => {
return c.json(statsJson('vocabularyCharts', await tracker.getVocabularyChartData()));
});
app.get('/api/stats/excluded-words', async (c) => { app.get('/api/stats/excluded-words', async (c) => {
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords())); return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
}); });
-11
View File
@@ -57,9 +57,7 @@ export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey:
export function buildStatsWindowOptions(options: { export function buildStatsWindowOptions(options: {
preloadPath: string; preloadPath: string;
bounds?: WindowGeometry | null; bounds?: WindowGeometry | null;
platform?: NodeJS.Platform;
}): BrowserWindowConstructorOptions { }): BrowserWindowConstructorOptions {
const platform = options.platform ?? process.platform;
return { return {
title: STATS_WINDOW_TITLE, title: STATS_WINDOW_TITLE,
x: options.bounds?.x, x: options.bounds?.x,
@@ -75,9 +73,6 @@ export function buildStatsWindowOptions(options: {
focusable: true, focusable: true,
acceptFirstMouse: true, acceptFirstMouse: true,
fullscreenable: false, fullscreenable: false,
// Panels join fullscreen Spaces on macOS without moving the user back to the
// desktop where SubMiner last owned a regular application window.
...(platform === 'darwin' ? { type: 'panel' as const } : {}),
backgroundColor: '#24273a', backgroundColor: '#24273a',
show: false, show: false,
webPreferences: { webPreferences: {
@@ -89,12 +84,6 @@ export function buildStatsWindowOptions(options: {
}; };
} }
export function shouldPresentStatsWindowAfterLoad(
platform: NodeJS.Platform = process.platform,
): boolean {
return platform === 'darwin';
}
export function resolveStatsWindowOuterBoundsForContent( export function resolveStatsWindowOuterBoundsForContent(
window: StatsWindowBoundsController, window: StatsWindowBoundsController,
target: WindowGeometry, target: WindowGeometry,
-25
View File
@@ -12,7 +12,6 @@ import {
scheduleStatsWindowPostShowReconciles, scheduleStatsWindowPostShowReconciles,
showStatsNativeConfirmDialog, showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
shouldPresentStatsWindowAfterLoad,
} from './stats-window-runtime'; } from './stats-window-runtime';
test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => { test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => {
@@ -41,30 +40,6 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w
assert.equal(options.webPreferences?.sandbox, true); assert.equal(options.webPreferences?.sandbox, true);
}); });
test('buildStatsWindowOptions uses a fullscreen auxiliary panel on macOS', () => {
const options = buildStatsWindowOptions({
preloadPath: '/tmp/preload-stats.js',
platform: 'darwin',
});
assert.equal(options.type, 'panel');
});
test('buildStatsWindowOptions remains a regular window off macOS', () => {
const options = buildStatsWindowOptions({
preloadPath: '/tmp/preload-stats.js',
platform: 'linux',
});
assert.equal(options.type, undefined);
});
test('stats panels present after document load on macOS', () => {
assert.equal(shouldPresentStatsWindowAfterLoad('darwin'), true);
assert.equal(shouldPresentStatsWindowAfterLoad('linux'), false);
assert.equal(shouldPresentStatsWindowAfterLoad('win32'), false);
});
test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => { test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
assert.equal( assert.equal(
shouldHideStatsWindowForInput( shouldHideStatsWindowForInput(
+2 -8
View File
@@ -13,7 +13,6 @@ import {
scheduleStatsWindowPostShowReconciles, scheduleStatsWindowPostShowReconciles,
showStatsNativeConfirmDialog, showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
shouldPresentStatsWindowAfterLoad,
STATS_WINDOW_TITLE, STATS_WINDOW_TITLE,
} from './stats-window-runtime.js'; } from './stats-window-runtime.js';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js'; import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
@@ -210,15 +209,10 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
options.onVisibilityChanged?.(false); options.onVisibilityChanged?.(false);
} }
}); });
const showInitialStatsWindow = () => { statsWindow.once('ready-to-show', () => {
if (!statsWindow) return; if (!statsWindow) return;
showStatsWindow(statsWindow, options); showStatsWindow(statsWindow, options);
}; });
if (shouldPresentStatsWindowAfterLoad()) {
statsWindow.webContents.once('did-finish-load', showInitialStatsWindow);
} else {
statsWindow.once('ready-to-show', showInitialStatsWindow);
}
statsWindow.on('blur', () => { statsWindow.on('blur', () => {
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) { if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
@@ -15,7 +15,7 @@
* layer that keeps the two views consistent by construction. * layer that keeps the two views consistent by construction.
* 2. Otherwise (embedded track nobody parsed, a source whose timings mpv has shifted) * 2. Otherwise (embedded track nobody parsed, a source whose timings mpv has shifted)
* fall back to timing alone. No authoring metadata is available live -- mpv delivers * fall back to timing alone. No authoring metadata is available live -- mpv delivers
* `sub-text/ass` after `sub-start`/`sub-end`, so any ASS text read here belongs to the * `sub-text-ass` after `sub-start`/`sub-end`, so any ASS text read here belongs to the
* previous event -- which puts this layer in the same position as the SRT path in * previous event -- which puts this layer in the same position as the SRT path in
* `subtitle-cue-dedup`, and it uses that path's deliberately strict bounds. * `subtitle-cue-dedup`, and it uses that path's deliberately strict bounds.
*/ */
-7
View File
@@ -24,17 +24,10 @@ import {
shouldForwardStartupArgvViaAppControl, shouldForwardStartupArgvViaAppControl,
applyBackgroundBootstrapCommandLineSwitches, applyBackgroundBootstrapCommandLineSwitches,
applyEarlyLinuxCommandLineSwitches, applyEarlyLinuxCommandLineSwitches,
resolveAppControlHandoffTimeoutMs,
resolveLinuxPasswordStoreValue, resolveLinuxPasswordStoreValue,
spawnDetachedApp, spawnDetachedApp,
} from './main-entry-runtime'; } from './main-entry-runtime';
test('app-control handoffs allow for macOS application activation latency', () => {
assert.equal(resolveAppControlHandoffTimeoutMs('darwin'), 3000);
assert.equal(resolveAppControlHandoffTimeoutMs('linux'), 500);
assert.equal(resolveAppControlHandoffTimeoutMs('win32'), 500);
});
test('detached app launch policy stays in the startup runtime utilities', () => { test('detached app launch policy stays in the startup runtime utilities', () => {
const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8'); const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
const runtimeSource = fs.readFileSync( const runtimeSource = fs.readFileSync(
-10
View File
@@ -14,8 +14,6 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
const MAX_TRANSPORTED_APP_ARGS = 256; const MAX_TRANSPORTED_APP_ARGS = 256;
const APP_NAME = 'SubMiner'; const APP_NAME = 'SubMiner';
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([ const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
'--alang', '--alang',
'--audio-file', '--audio-file',
@@ -188,14 +186,6 @@ export function shouldForwardStartupArgvViaAppControl(
return hasExplicitCommand(args); return hasExplicitCommand(args);
} }
export function resolveAppControlHandoffTimeoutMs(
platform: NodeJS.Platform = process.platform,
): number {
return platform === 'darwin'
? MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS
: DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS;
}
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null { function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
const rawCount = env[TRANSPORTED_APP_ARGC_ENV]; const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
if (rawCount === undefined) { if (rawCount === undefined) {
+1 -2
View File
@@ -9,7 +9,6 @@ import {
normalizeLaunchMpvTargets, normalizeLaunchMpvTargets,
normalizeStartupArgv, normalizeStartupArgv,
applyEarlyLinuxCommandLineSwitches, applyEarlyLinuxCommandLineSwitches,
resolveAppControlHandoffTimeoutMs,
sanitizeStartupEnv, sanitizeStartupEnv,
sanitizeBackgroundEnv, sanitizeBackgroundEnv,
sanitizeHelpEnv, sanitizeHelpEnv,
@@ -215,7 +214,7 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
const result = await sendAppControlCommand(process.argv, { const result = await sendAppControlCommand(process.argv, {
configDir: userDataPath, configDir: userDataPath,
timeoutMs: resolveAppControlHandoffTimeoutMs(), timeoutMs: 500,
}); });
if (result.ok) { if (result.ok) {
app.exit(0); app.exit(0);
+1 -5
View File
@@ -331,7 +331,6 @@ import {
acquireYoutubeSubtitleTrack, acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks, acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate'; } from './core/services/youtube/generate';
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache'; import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
@@ -5010,9 +5009,6 @@ function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
function initializeOverlayRuntime(): void { function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler(); initializeOverlayRuntimeHandler();
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
overlayModalRuntime.primeModalWindow();
}
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate, refreshCurrentSubtitleAfterKnownWordUpdate,
@@ -5470,7 +5466,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
senderWindow === modalWindow && senderWindow === modalWindow &&
!senderWindow.isDestroyed() !senderWindow.isDestroyed()
) { ) {
applyOverlayClickThrough(senderWindow); senderWindow.setIgnoreMouseEvents(true, { forward: true });
senderWindow.hide(); senderWindow.hide();
} }
handleOverlayModalClosedHandler(modal); handleOverlayModalClosedHandler(modal);
+49 -321
View File
@@ -16,7 +16,6 @@ type MockWindow = {
loading: boolean; loading: boolean;
url: string; url: string;
contentReady: boolean; contentReady: boolean;
documentLoaded: boolean;
loadCallbacks: Array<() => void>; loadCallbacks: Array<() => void>;
readyToShowCallbacks: Array<() => void>; readyToShowCallbacks: Array<() => void>;
}; };
@@ -32,7 +31,6 @@ function createMockWindow(): MockWindow & {
getShowCount: () => number; getShowCount: () => number;
getHideCount: () => number; getHideCount: () => number;
show: () => void; show: () => void;
showInactive: () => void;
hide: () => void; hide: () => void;
destroy: () => void; destroy: () => void;
focus: () => void; focus: () => void;
@@ -63,7 +61,6 @@ function createMockWindow(): MockWindow & {
loading: false, loading: false,
url: 'file:///overlay/index.html?layer=modal', url: 'file:///overlay/index.html?layer=modal',
contentReady: true, contentReady: true,
documentLoaded: true,
loadCallbacks: [], loadCallbacks: [],
readyToShowCallbacks: [], readyToShowCallbacks: [],
}; };
@@ -87,10 +84,6 @@ function createMockWindow(): MockWindow & {
state.visible = true; state.visible = true;
state.showCount += 1; state.showCount += 1;
}, },
showInactive: () => {
state.visible = true;
state.showCount += 1;
},
hide: () => { hide: () => {
state.visible = false; state.visible = false;
state.hideCount += 1; state.hideCount += 1;
@@ -103,10 +96,6 @@ function createMockWindow(): MockWindow & {
state.focused = true; state.focused = true;
}, },
emitDidFinishLoad: () => { emitDidFinishLoad: () => {
state.documentLoaded = true;
(
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
).__subminerOverlayDocumentLoaded = true;
const callbacks = state.loadCallbacks.splice(0); const callbacks = state.loadCallbacks.splice(0);
for (const callback of callbacks) { for (const callback of callbacks) {
callback(); callback();
@@ -208,22 +197,9 @@ function createMockWindow(): MockWindow & {
}, },
}); });
Object.defineProperty(window, 'documentLoaded', {
get: () => state.documentLoaded,
set: (value: boolean) => {
state.documentLoaded = value;
(
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
).__subminerOverlayDocumentLoaded = value;
},
});
( (
window as typeof window & { __subminerOverlayContentReady?: boolean } window as typeof window & { __subminerOverlayContentReady?: boolean }
).__subminerOverlayContentReady = state.contentReady; ).__subminerOverlayContentReady = state.contentReady;
(
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
).__subminerOverlayDocumentLoaded = state.documentLoaded;
return window; return window;
} }
@@ -283,73 +259,6 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
assert.deepEqual(window.sent, [['jimaku:open']]); assert.deepEqual(window.sent, [['jimaku:open']]);
}); });
for (const platform of ['darwin', 'win32'] as const) {
test(`primeModalWindow creates and warms a hidden modal on ${platform}`, () => {
const modalWindow = createMockWindow();
modalWindow.loading = true;
modalWindow.url = '';
modalWindow.contentReady = false;
modalWindow.documentLoaded = false;
let currentModal: ReturnType<typeof createMockWindow> | null = null;
let createCalls = 0;
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => currentModal as never,
createModalWindow: () => {
createCalls += 1;
currentModal = modalWindow;
return modalWindow as never;
},
getModalGeometry: () => ({ x: 1, y: 2, width: 300, height: 200 }),
setModalWindowBounds: () => {},
},
{ platform },
);
assert.equal(runtime.primeModalWindow(), true);
assert.equal(createCalls, 1);
assert.equal(modalWindow.isVisible(), false);
modalWindow.loading = false;
modalWindow.url = 'file:///overlay/index.html?layer=modal';
modalWindow.emitDidFinishLoad();
modalWindow.emitReadyToShow();
modalWindow.contentReady = true;
assert.equal(
runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
restoreOnModalClose: 'session-help',
preferModalWindow: true,
}),
true,
);
assert.equal(createCalls, 1);
assert.equal(modalWindow.isVisible(), true);
assert.deepEqual(modalWindow.sent, [['session-help:open']]);
});
}
test('primeModalWindow leaves Linux modal creation lazy', () => {
let createCalls = 0;
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => null,
createModalWindow: () => {
createCalls += 1;
return createMockWindow() as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{ platform: 'linux' },
);
assert.equal(runtime.primeModalWindow(), false);
assert.equal(createCalls, 0);
});
test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => { test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
@@ -392,7 +301,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
window.loading = false; window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal'; window.url = 'file:///overlay/index.html?layer=modal';
window.emitDidFinishLoad(); window.emitDidFinishLoad();
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, []);
window.contentReady = true; window.contentReady = true;
window.emitReadyToShow(); window.emitReadyToShow();
@@ -402,18 +311,15 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
assert.equal(window.getShowCount(), 1); assert.equal(window.getShowCount(), 1);
}); });
test('handleOverlayModalClosed keeps the modal window warm after all pending modals close', () => { test('handleOverlayModalClosed hides modal window only after all pending modals close', () => {
const window = createMockWindow(); const window = createMockWindow();
const runtime = createOverlayModalRuntimeService( const runtime = createOverlayModalRuntimeService({
{ getMainWindow: () => null,
getMainWindow: () => null, getModalWindow: () => window as never,
getModalWindow: () => window as never, createModalWindow: () => window as never,
createModalWindow: () => window as never, getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), setModalWindowBounds: () => {},
setModalWindowBounds: () => {}, });
},
{ platform: 'darwin' },
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options', restoreOnModalClose: 'runtime-options',
@@ -436,9 +342,7 @@ test('handleOverlayModalClosed keeps the modal window warm after all pending mod
assert.equal(window.isDestroyed(), false); assert.equal(window.isDestroyed(), false);
runtime.handleOverlayModalClosed('subsync'); runtime.handleOverlayModalClosed('subsync');
assert.equal(window.isDestroyed(), false); assert.equal(window.isDestroyed(), true);
assert.equal(window.isVisible(), false);
assert.equal(window.ignoreMouseEvents, true);
}); });
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => { test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
@@ -560,46 +464,6 @@ test('modal window path restores visible main overlay before modal input deactiv
assert.deepEqual(events, ['state:true:visible:true', 'state:false:visible:true']); assert.deepEqual(events, ['state:true:visible:true', 'state:false:visible:true']);
}); });
test('macOS maps a new modal panel before focusing SubMiner and hiding the subtitle overlay', () => {
const mainWindow = createMockWindow();
mainWindow.visible = true;
const modalWindow = createMockWindow();
const events: string[] = [];
const showInactive = modalWindow.showInactive;
modalWindow.showInactive = () => {
events.push('show-inactive');
showInactive();
};
const hideMainWindow = mainWindow.hide;
mainWindow.hide = () => {
events.push('hide-main');
hideMainWindow();
};
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => mainWindow as never,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{
platform: 'darwin',
focusApplication: () => events.push('focus-application'),
},
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
preferModalWindow: true,
});
runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(events, ['show-inactive', 'focus-application', 'hide-main']);
assert.equal(modalWindow.isVisible(), true);
assert.equal(mainWindow.isVisible(), false);
});
test('modal window path runs final close handoff before modal input deactivates', () => { test('modal window path runs final close handoff before modal input deactivates', () => {
const mainWindow = createMockWindow(); const mainWindow = createMockWindow();
mainWindow.visible = true; mainWindow.visible = true;
@@ -786,18 +650,15 @@ test('handleOverlayModalClosed is a no-op when no modal window can be targeted',
assert.deepEqual(state, []); assert.deepEqual(state, []);
}); });
test('handleOverlayModalClosed hides and retains modal window for single kiku modal', () => { test('handleOverlayModalClosed destroys modal window for single kiku modal', () => {
const window = createMockWindow(); const window = createMockWindow();
const runtime = createOverlayModalRuntimeService( const runtime = createOverlayModalRuntimeService({
{ getMainWindow: () => null,
getMainWindow: () => null, getModalWindow: () => window as never,
getModalWindow: () => window as never, createModalWindow: () => window as never,
createModalWindow: () => window as never, getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), setModalWindowBounds: () => {},
setModalWindowBounds: () => {}, });
},
{ platform: 'darwin' },
);
runtime.sendToActiveOverlayWindow( runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open', 'kiku:field-grouping-open',
@@ -808,9 +669,7 @@ test('handleOverlayModalClosed hides and retains modal window for single kiku mo
); );
runtime.handleOverlayModalClosed('kiku'); runtime.handleOverlayModalClosed('kiku');
assert.equal(window.isDestroyed(), false); assert.equal(window.isDestroyed(), true);
assert.equal(window.isVisible(), false);
assert.equal(window.ignoreMouseEvents, true);
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0); assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
}); });
@@ -860,10 +719,8 @@ test('modal fallback reveal skips showing window when content is not ready', asy
assert.equal(window.ignoreMouseEvents, false); assert.equal(window.ignoreMouseEvents, false);
}); });
test('sendToActiveOverlayWindow delivers on first modal load without waiting for ready-to-show', () => { test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => {
const window = createMockWindow(); const window = createMockWindow();
window.loading = true;
window.url = '';
window.contentReady = false; window.contentReady = false;
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
@@ -881,100 +738,16 @@ test('sendToActiveOverlayWindow delivers on first modal load without waiting for
assert.equal(sent, true); assert.equal(sent, true);
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, []);
window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal';
window.emitDidFinishLoad(); window.emitDidFinishLoad();
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, []);
window.contentReady = true; window.contentReady = true;
window.emitReadyToShow(); window.emitReadyToShow();
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, [['runtime-options:open']]);
}); });
test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => {
const window = createMockWindow();
window.contentReady = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => {
throw new Error('modal window should not be created when already present');
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
assert.equal(
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
}),
true,
);
assert.deepEqual(window.sent, [['runtime-options:open']]);
window.contentReady = true;
window.emitReadyToShow();
assert.deepEqual(window.sent, [['runtime-options:open']]);
});
test('sendToActiveOverlayWindow does not infer document readiness from a pending file URL', () => {
const window = createMockWindow();
window.contentReady = false;
window.documentLoaded = false;
window.loading = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => {
throw new Error('modal window should not be created when already present');
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
assert.equal(
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
}),
true,
);
assert.deepEqual(window.sent, []);
window.emitDidFinishLoad();
assert.deepEqual(window.sent, [['runtime-options:open']]);
});
test('sendToActiveOverlayWindow rejects stale content readiness during document reload', () => {
const window = createMockWindow();
window.contentReady = true;
window.documentLoaded = false;
window.loading = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => {
throw new Error('modal window should not be created when already present');
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
assert.equal(
runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
restoreOnModalClose: 'session-help',
}),
true,
);
assert.deepEqual(window.sent, []);
window.emitDidFinishLoad();
assert.deepEqual(window.sent, [['session-help:open']]);
});
test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => { test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => {
const window = createMockWindow(); const window = createMockWindow();
window.loading = true;
window.url = '';
window.contentReady = false; window.contentReady = false;
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
@@ -1000,73 +773,29 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef
); );
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, []);
window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal';
window.emitDidFinishLoad(); window.emitDidFinishLoad();
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); assert.deepEqual(window.sent, []);
window.contentReady = true; window.contentReady = true;
window.emitReadyToShow(); window.emitReadyToShow();
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
}); });
test('modal reopen reuses the warm window and shows it immediately on macOS', () => { test('modal reopen creates a fresh window after close destroys the previous one', () => {
const modalWindow = createMockWindow();
let createCalls = 0;
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => modalWindow as never,
createModalWindow: () => {
createCalls += 1;
return modalWindow as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{ platform: 'darwin' },
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
runtime.notifyOverlayModalOpened('runtime-options');
runtime.handleOverlayModalClosed('runtime-options');
assert.equal(modalWindow.isDestroyed(), false);
assert.equal(modalWindow.isVisible(), false);
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(sent, true);
assert.equal(createCalls, 0);
assert.equal(modalWindow.isVisible(), true);
assert.equal(modalWindow.getShowCount(), 2);
});
test('modal reopen on Windows uses a fresh prewarmed interactive window', () => {
const firstWindow = createMockWindow(); const firstWindow = createMockWindow();
const replacementWindow = createMockWindow(); const secondWindow = createMockWindow();
let currentModal = firstWindow; let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
let createCalls = 0;
const runtime = createOverlayModalRuntimeService( const runtime = createOverlayModalRuntimeService({
{ getMainWindow: () => null,
getMainWindow: () => null, getModalWindow: () => currentModal as never,
getModalWindow: () => currentModal as never, createModalWindow: () => {
createModalWindow: () => { currentModal = secondWindow;
createCalls += 1; return secondWindow as never;
currentModal = replacementWindow;
return replacementWindow as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
}, },
{ platform: 'win32' }, getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
); setModalWindowBounds: () => {},
});
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options', restoreOnModalClose: 'runtime-options',
@@ -1075,30 +804,30 @@ test('modal reopen on Windows uses a fresh prewarmed interactive window', () =>
runtime.handleOverlayModalClosed('runtime-options'); runtime.handleOverlayModalClosed('runtime-options');
assert.equal(firstWindow.isDestroyed(), true); assert.equal(firstWindow.isDestroyed(), true);
assert.equal(currentModal, replacementWindow);
assert.equal(replacementWindow.isVisible(), false);
assert.equal(createCalls, 1);
const sent = runtime.sendToActiveOverlayWindow('session-help:open', undefined, { const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'session-help', restoreOnModalClose: 'runtime-options',
}); });
assert.equal(sent, true); assert.equal(sent, true);
assert.equal(createCalls, 1); assert.equal(currentModal, secondWindow);
assert.equal(replacementWindow.isVisible(), true); assert.equal(secondWindow.getShowCount(), 0);
assert.equal(replacementWindow.ignoreMouseEvents, false);
assert.deepEqual(replacementWindow.sent, [['session-help:open']]);
}); });
test('modal reopen on the warm window notifies state change for each lifecycle', () => { test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => {
const modalWindow = createMockWindow(); const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
const state: boolean[] = []; const state: boolean[] = [];
const runtime = createOverlayModalRuntimeService( const runtime = createOverlayModalRuntimeService(
{ {
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => modalWindow as never, getModalWindow: () => currentModal as never,
createModalWindow: () => modalWindow as never, createModalWindow: () => {
currentModal = secondWindow;
return secondWindow as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {}, setModalWindowBounds: () => {},
}, },
@@ -1106,7 +835,6 @@ test('modal reopen on the warm window notifies state change for each lifecycle',
onModalStateChange: (active: boolean): void => { onModalStateChange: (active: boolean): void => {
state.push(active); state.push(active);
}, },
platform: 'darwin',
}, },
); );
@@ -1117,7 +845,7 @@ test('modal reopen on the warm window notifies state change for each lifecycle',
runtime.handleOverlayModalClosed('runtime-options'); runtime.handleOverlayModalClosed('runtime-options');
assert.deepEqual(state, [true, false]); assert.deepEqual(state, [true, false]);
assert.equal(modalWindow.isDestroyed(), false); assert.equal(firstWindow.isDestroyed(), true);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options', restoreOnModalClose: 'runtime-options',
@@ -1125,7 +853,7 @@ test('modal reopen on the warm window notifies state change for each lifecycle',
runtime.notifyOverlayModalOpened('runtime-options'); runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(state, [true, false, true]); assert.deepEqual(state, [true, false, true]);
assert.equal(modalWindow.isVisible(), true); assert.equal(currentModal, secondWindow);
}); });
test('visible stale modal window is made interactive again before reopening', () => { test('visible stale modal window is made interactive again before reopening', () => {
+19 -101
View File
@@ -2,11 +2,7 @@ import type { BrowserWindow } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts'; import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement'; import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
import { applyOverlayClickThrough } from '../core/services/overlay-click-through'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
import {
OVERLAY_WINDOW_CONTENT_READY_FLAG,
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
} from '../core/services/overlay-window-flags';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire // The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
@@ -43,7 +39,6 @@ export interface OverlayWindowResolver {
} }
export interface OverlayModalRuntime { export interface OverlayModalRuntime {
primeModalWindow: () => boolean;
sendToActiveOverlayWindow: ( sendToActiveOverlayWindow: (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
@@ -64,8 +59,6 @@ export interface OverlayModalRuntime {
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>; type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
export interface OverlayModalRuntimeOptions { export interface OverlayModalRuntimeOptions {
platform?: NodeJS.Platform;
focusApplication?: () => void;
onModalStateChange?: (isActive: boolean) => void; onModalStateChange?: (isActive: boolean) => void;
onFinalModalClosed?: () => void; onFinalModalClosed?: () => void;
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle; scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
@@ -86,11 +79,6 @@ export function createOverlayModalRuntimeService(
let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null; let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>(); const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>();
const platform = options.platform ?? process.platform;
const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
const reuseModalWindowAfterClose = platform === 'darwin';
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle => const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs); (options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
const clearRevealFallback = (timeout: RevealFallbackHandle): void => const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
@@ -146,11 +134,7 @@ export function createOverlayModalRuntimeService(
} }
const overlayWindow = window as BrowserWindow & { const overlayWindow = window as BrowserWindow & {
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
}; };
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === false) {
return false;
}
if ( if (
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' && typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
@@ -161,50 +145,6 @@ export function createOverlayModalRuntimeService(
return currentURL !== '' && currentURL !== 'about:blank'; return currentURL !== '' && currentURL !== 'about:blank';
}; };
const isWindowLoadedForIpc = (window: BrowserWindow): boolean => {
if (window.isDestroyed() || window.webContents.isLoading()) {
return false;
}
const overlayWindow = window as BrowserWindow & {
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
};
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] !== true) {
return false;
}
const currentURL = window.webContents.getURL();
return currentURL !== '' && currentURL !== 'about:blank';
};
const markModalWindowPrimed = (window: BrowserWindow): void => {
if (deps.getModalWindow() !== window || !isWindowLoadedForIpc(window)) {
return;
}
modalWindowPrimedForImmediateShow = true;
};
const primeModalWindow = (): boolean => {
if (!shouldPrimeModalWindow) {
return false;
}
const modalWindow = resolveModalWindow();
if (!modalWindow) {
return false;
}
deps.setModalWindowBounds(deps.getModalGeometry());
if (isWindowReadyForIpc(modalWindow)) {
modalWindowPrimedForImmediateShow = true;
return true;
}
if (!modalWindowPrimeListenersRegistered.has(modalWindow)) {
modalWindowPrimeListenersRegistered.add(modalWindow);
modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow));
modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow));
}
return true;
};
const elevateModalWindow = (window: BrowserWindow): void => { const elevateModalWindow = (window: BrowserWindow): void => {
if (window.isDestroyed()) return; if (window.isDestroyed()) return;
window.setAlwaysOnTop(true, 'screen-saver', 3); window.setAlwaysOnTop(true, 'screen-saver', 3);
@@ -265,19 +205,16 @@ export function createOverlayModalRuntimeService(
} }
let delivered = false; let delivered = false;
const deliver = (isReady: () => boolean): void => { const deliverWhenReady = (): void => {
if (delivered || window.isDestroyed() || !isReady()) { if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
return; return;
} }
delivered = true; delivered = true;
sendNow(window); sendNow(window);
}; };
// A hidden macOS panel may not emit ready-to-show until it is presented. The window.webContents.once('did-finish-load', deliverWhenReady);
// renderer can safely receive IPC as soon as its document has finished loading. window.once('ready-to-show', deliverWhenReady);
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
deliver(() => isWindowLoadedForIpc(window));
}; };
const showModalWindow = ( const showModalWindow = (
@@ -287,20 +224,13 @@ export function createOverlayModalRuntimeService(
} = { passThroughMouseEvents: false }, } = { passThroughMouseEvents: false },
): void => { ): void => {
setWindowFocusable(window); setWindowFocusable(window);
const wasVisible = window.isVisible(); requestOverlayApplicationFocus();
if (!wasVisible && platform === 'darwin') { if (!window.isVisible()) {
// Mapping the panel first keeps it attached to mpv's active fullscreen Space.
window.showInactive();
focusApplication();
} else {
focusApplication();
}
if (!wasVisible && platform !== 'darwin') {
window.show(); window.show();
} }
elevateModalWindow(window); elevateModalWindow(window);
if (options.passThroughMouseEvents) { if (options.passThroughMouseEvents) {
applyOverlayClickThrough(window, platform === 'win32'); window.setIgnoreMouseEvents(true, { forward: true });
} else { } else {
window.setIgnoreMouseEvents(false); window.setIgnoreMouseEvents(false);
} }
@@ -315,11 +245,11 @@ export function createOverlayModalRuntimeService(
const ensureModalWindowInteractive = (window: BrowserWindow): void => { const ensureModalWindowInteractive = (window: BrowserWindow): void => {
setWindowFocusable(window); setWindowFocusable(window);
requestOverlayApplicationFocus();
window.setIgnoreMouseEvents(false); window.setIgnoreMouseEvents(false);
elevateModalWindow(window); elevateModalWindow(window);
if (window.isVisible()) { if (window.isVisible()) {
focusApplication();
window.focus(); window.focus();
window.webContents.focus(); window.webContents.focus();
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window); const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
@@ -361,7 +291,7 @@ export function createOverlayModalRuntimeService(
mainWindowMousePassthroughForcedByModal = false; mainWindowMousePassthroughForcedByModal = false;
return; return;
} }
applyOverlayClickThrough(mainWindow, platform === 'win32'); mainWindow.setIgnoreMouseEvents(true, { forward: true });
mainWindowMousePassthroughForcedByModal = true; mainWindowMousePassthroughForcedByModal = true;
return; return;
} }
@@ -517,21 +447,9 @@ export function createOverlayModalRuntimeService(
if (restoreVisibleOverlayOnModalClose.size === 0) { if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal(); clearPendingModalWindowReveal();
if (modalWindow && !modalWindow.isDestroyed()) { if (modalWindow && !modalWindow.isDestroyed()) {
if (reuseModalWindowAfterClose) { modalWindow.destroy();
applyOverlayClickThrough(modalWindow, false);
modalWindow.hide();
markModalWindowPrimed(modalWindow);
} else {
modalWindow.destroy();
modalWindowPrimedForImmediateShow = false;
// Reusing a transparent click-through BrowserWindow can leave later modal sessions
// non-interactive on Windows. Recycle the renderer after every close, then warm its
// replacement so the next shortcut still opens promptly.
if (platform === 'win32') {
primeModalWindow();
}
}
} }
modalWindowPrimedForImmediateShow = false;
mainWindowMousePassthroughForcedByModal = false; mainWindowMousePassthroughForcedByModal = false;
setMainWindowVisibilityForModal(false); setMainWindowVisibilityForModal(false);
try { try {
@@ -560,16 +478,17 @@ export function createOverlayModalRuntimeService(
} }
const modalWindow = deps.getModalWindow(); const modalWindow = deps.getModalWindow();
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
} else {
showModalWindow(targetWindow);
}
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) { if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
setMainWindowMousePassthroughForModal(true); setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true); setMainWindowVisibilityForModal(true);
} }
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
return;
}
showModalWindow(targetWindow);
}; };
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> => const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
@@ -596,7 +515,6 @@ export function createOverlayModalRuntimeService(
}); });
return { return {
primeModalWindow,
sendToActiveOverlayWindow, sendToActiveOverlayWindow,
openRuntimeOptionsPalette, openRuntimeOptionsPalette,
openJimaku, openJimaku,
+1 -3
View File
@@ -1,5 +1,3 @@
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
type StatsOverlayVisibilityWindow = { type StatsOverlayVisibilityWindow = {
isDestroyed: () => boolean; isDestroyed: () => boolean;
isVisible: () => boolean; isVisible: () => boolean;
@@ -10,7 +8,7 @@ function makeOverlayMousePassive(window: StatsOverlayVisibilityWindow | null): v
if (!window || window.isDestroyed() || !window.isVisible()) { if (!window || window.isDestroyed() || !window.isVisible()) {
return; return;
} }
applyOverlayClickThrough(window); window.setIgnoreMouseEvents(true, { forward: true });
} }
export function createStatsOverlayVisibilityChangeHandler(deps: { export function createStatsOverlayVisibilityChangeHandler(deps: {
+1 -17
View File
@@ -31,20 +31,6 @@ function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; command
return { spawn, children, commands }; return { spawn, children, commands };
} }
async function waitForResult<T>(promise: Promise<T>, timeoutMs = 3000): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error('Timed out waiting for result.')), timeoutMs);
}),
]);
} finally {
if (timeout !== null) clearTimeout(timeout);
}
}
test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => { test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => {
const { spawn, children, commands } = makeSpawn(); const { spawn, children, commands } = makeSpawn();
const events: SyncProgressEvent[] = []; const events: SyncProgressEvent[] = [];
@@ -110,9 +96,7 @@ test('runSyncLauncher settles after exit when close never arrives', async () =>
// so `close` never fires. // so `close` never fires.
child.emit('exit', 1, null); child.emit('exit', 1, null);
// Keep the isolated Bun test process alive while the production drain timer const result = await handle.done;
// remains unref'ed, and fail instead of hanging if the result never settles.
const result = await waitForResult(handle.done);
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.match(result.error ?? '', /remote refused/); assert.match(result.error ?? '', /remote refused/);
}); });
+1 -1
View File
@@ -66,7 +66,7 @@ export function runSyncLauncher(options: {
spawn?: SyncLauncherSpawn; spawn?: SyncLauncherSpawn;
timeoutMs?: number; timeoutMs?: number;
}): SyncLauncherRunHandle { }): SyncLauncherRunHandle {
const spawn: SyncLauncherSpawn = const spawn =
options.spawn ?? options.spawn ??
((command, args) => { ((command, args) => {
// The child must boot as a full Electron app (its entry handles // The child must boot as a full Electron app (its entry handles
@@ -1,7 +1,6 @@
import { type BrowserWindow, screen } from 'electron'; import { type BrowserWindow, screen } from 'electron';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services'; import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args'; import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
import type { OverlayContentMeasurement, WindowGeometry } from '../../types'; import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers'; import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
@@ -604,7 +603,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
if (active) { if (active) {
mainWindow.setIgnoreMouseEvents(false); mainWindow.setIgnoreMouseEvents(false);
} else { } else {
applyOverlayClickThrough(mainWindow); mainWindow.setIgnoreMouseEvents(true, { forward: true });
} }
} }
+22
View File
@@ -50,6 +50,24 @@ export interface StatsKnownWordsSummary {
knownWordCount: number; knownWordCount: number;
} }
export interface StatsVocabularySummary {
uniqueWords: number;
uniqueWordsWithoutNames: number;
uniqueKanji: number;
newThisWeek: number;
newThisWeekWithoutNames: number;
knownWordCount: number | null;
knownWordCountWithoutNames: number | null;
}
export interface StatsVocabularyCharts {
ready: boolean;
topWords: Array<{ wordId: number; headword: string; frequency: number }>;
topWordsWithoutNames: Array<{ wordId: number; headword: string; frequency: number }>;
newWordsTimeline: Array<{ epochDay: number; wordCount: number }>;
newWordsTimelineWithoutNames: Array<{ epochDay: number; wordCount: number }>;
}
export interface StatsAnilistSearchResult { export interface StatsAnilistSearchResult {
id: number; id: number;
episodes: number | null; episodes: number | null;
@@ -164,6 +182,8 @@ export interface StatsJsonResponseMap {
sessionEvents: SessionEvent[]; sessionEvents: SessionEvent[];
sessionKnownWordsTimeline: StatsSessionKnownWordsTimelinePoint[]; sessionKnownWordsTimeline: StatsSessionKnownWordsTimelinePoint[];
vocabulary: VocabularyEntry[]; vocabulary: VocabularyEntry[];
vocabularySummary: StatsVocabularySummary;
vocabularyCharts: StatsVocabularyCharts;
excludedWords: StatsExcludedWord[]; excludedWords: StatsExcludedWord[];
setExcludedWords: StatsOkResponse; setExcludedWords: StatsOkResponse;
duplicateLineCleanup: StatsDuplicateLineCleanupResult; duplicateLineCleanup: StatsDuplicateLineCleanupResult;
@@ -222,6 +242,8 @@ export interface StatsHttpClient {
getSessionEvents: (id: number, limit?: number, eventTypes?: number[]) => Promise<SessionEvent[]>; getSessionEvents: (id: number, limit?: number, eventTypes?: number[]) => Promise<SessionEvent[]>;
getSessionKnownWordsTimeline: (id: number) => Promise<StatsSessionKnownWordsTimelinePoint[]>; getSessionKnownWordsTimeline: (id: number) => Promise<StatsSessionKnownWordsTimelinePoint[]>;
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>; getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
getVocabularySummary: () => Promise<StatsVocabularySummary>;
getVocabularyCharts: () => Promise<StatsVocabularyCharts>;
getExcludedWords: () => Promise<StatsExcludedWord[]>; getExcludedWords: () => Promise<StatsExcludedWord[]>;
setExcludedWords: (words: StatsExcludedWord[]) => Promise<void>; setExcludedWords: (words: StatsExcludedWord[]) => Promise<void>;
cleanupDuplicateLines: ( cleanupDuplicateLines: (
+46 -118
View File
@@ -1,4 +1,4 @@
import { execFile } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import koffi from 'koffi'; import koffi from 'koffi';
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match'; import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
@@ -173,125 +173,43 @@ function getProcessNameByPid(pid: number): string | null {
} }
} }
// Short-lived cache so the 250ms poll doesn't re-query every top-level window's process const processCommandLineCache = new Map<number, string>();
// on each pass. The TTL bounds staleness from PID reuse.
const PROCESS_NAME_CACHE_TTL_MS = 5_000;
const PROCESS_NAME_CACHE_PRUNE_THRESHOLD = 512;
const processNameCache = new Map<number, { name: string | null; expiresAtMs: number }>();
function getCachedProcessNameByPid(pid: number): string | null {
const nowMs = Date.now();
const cached = processNameCache.get(pid);
if (cached && cached.expiresAtMs > nowMs) {
return cached.name;
}
const name = getProcessNameByPid(pid);
processNameCache.set(pid, { name, expiresAtMs: nowMs + PROCESS_NAME_CACHE_TTL_MS });
return name;
}
function pruneExpiredProcessNames(nowMs: number): void {
if (processNameCache.size <= PROCESS_NAME_CACHE_PRUNE_THRESHOLD) return;
for (const [pid, entry] of processNameCache) {
if (entry.expiresAtMs <= nowMs) {
processNameCache.delete(pid);
}
}
}
type ProcessCommandLineCacheEntry =
| { state: 'resolved'; commandLine: string; expiresAtMs: number; refreshInFlight: boolean }
| { state: 'pending' }
| { state: 'failed'; retryAtMs: number; backoffMs: number };
const COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS = 2_000;
const COMMAND_LINE_RETRY_MAX_BACKOFF_MS = 30_000;
// A process command line never changes, so a resolved entry only has to expire to survive
// Windows PID reuse (a dead mpv's PID handed to a new instance, whose stale socket path would
// otherwise match the wrong window forever). Longer than the process-name TTL because each
// refresh costs a PowerShell spawn, and the cached value keeps being served while the refresh
// runs, so expiry never interrupts window matching.
const COMMAND_LINE_CACHE_TTL_MS = 60_000;
const processCommandLineCache = new Map<number, ProcessCommandLineCacheEntry>();
function queryProcessCommandLine(
pid: number,
onResult: (commandLine: string | null) => void,
): void {
execFile(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
`$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`,
],
{
encoding: 'utf8',
windowsHide: true,
timeout: 1500,
},
(error, stdout) => {
const output = error ? '' : stdout.trim();
onResult(output.length > 0 ? output : null);
},
);
}
// Resolves a process command line via a background PowerShell lookup. Returns null until the
// first lookup completes; the caller's next poll picks up the cached result. Failures are
// negative-cached with exponential backoff: the synchronous version of this lookup could
// block the main thread for its full 1.5s timeout on every 250ms poll, which (combined with
// the forward:true mouse hook) stalled mouse input system-wide.
function getProcessCommandLineByPid(pid: number): string | null { function getProcessCommandLineByPid(pid: number): string | null {
const entry = processCommandLineCache.get(pid); if (processCommandLineCache.has(pid)) {
const nowMs = Date.now(); return processCommandLineCache.get(pid) ?? null;
if (entry?.state === 'resolved') {
if (nowMs >= entry.expiresAtMs && !entry.refreshInFlight) {
entry.refreshInFlight = true;
queryProcessCommandLine(pid, (commandLine) => {
processCommandLineCache.set(pid, {
state: 'resolved',
// A failed refresh is usually a transient query error rather than a dead process
// (a gone process owns no window, so it is never looked up again). Keep the last
// known command line and re-check after the next TTL.
commandLine: commandLine ?? entry.commandLine,
expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS,
refreshInFlight: false,
});
});
}
return entry.commandLine;
} }
if (entry?.state === 'pending') return null; let commandLine: string | null = null;
if (entry?.state === 'failed' && nowMs < entry.retryAtMs) return null; try {
const output = execFileSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
`$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`,
],
{
encoding: 'utf8',
windowsHide: true,
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 1500,
},
).trim();
commandLine = output.length > 0 ? output : null;
} catch {
commandLine = null;
}
const nextBackoffMs = if (commandLine !== null) {
entry?.state === 'failed' processCommandLineCache.set(pid, commandLine);
? Math.min(entry.backoffMs * 2, COMMAND_LINE_RETRY_MAX_BACKOFF_MS) } else {
: COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS; processCommandLineCache.delete(pid);
processCommandLineCache.set(pid, { state: 'pending' }); }
queryProcessCommandLine(pid, (commandLine) => { return commandLine;
if (commandLine !== null) {
processCommandLineCache.set(pid, {
state: 'resolved',
commandLine,
expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS,
refreshInFlight: false,
});
} else {
processCommandLineCache.set(pid, {
state: 'failed',
retryAtMs: Date.now() + nextBackoffMs,
backoffMs: nextBackoffMs,
});
}
});
return null;
} }
export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult { export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult {
@@ -299,7 +217,8 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
const matches: MpvWindowMatch[] = []; const matches: MpvWindowMatch[] = [];
let hasMinimized = false; let hasMinimized = false;
let hasFocused = false; let hasFocused = false;
pruneExpiredProcessNames(Date.now()); const processNameCache = new Map<number, string | null>();
const processCommandLineLookupCache = new Map<number, string | null>();
const cb = koffi.register((hwnd: number, _lParam: number) => { const cb = koffi.register((hwnd: number, _lParam: number) => {
if (!IsWindowVisible(hwnd)) return true; if (!IsWindowVisible(hwnd)) return true;
@@ -309,12 +228,21 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
const pidValue = pid[0]!; const pidValue = pid[0]!;
if (pidValue === 0) return true; if (pidValue === 0) return true;
const processName = getCachedProcessNameByPid(pidValue); let processName = processNameCache.get(pidValue);
if (processName === undefined) {
processName = getProcessNameByPid(pidValue);
processNameCache.set(pidValue, processName);
}
if (!processName || processName.toLowerCase() !== 'mpv') return true; if (!processName || processName.toLowerCase() !== 'mpv') return true;
let commandLine: string | null = null; let commandLine: string | null = null;
if (targetSocketPath) { if (targetSocketPath) {
commandLine = getProcessCommandLineByPid(pidValue); commandLine = processCommandLineLookupCache.get(pidValue) ?? null;
if (!processCommandLineLookupCache.has(pidValue)) {
commandLine = getProcessCommandLineByPid(pidValue);
processCommandLineLookupCache.set(pidValue, commandLine);
}
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) { if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
return true; return true;
} }
@@ -98,10 +98,10 @@ export function DuplicateLineCleanup({ onClose, onCleaned }: DuplicateLineCleanu
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
<p className="text-xs leading-relaxed text-ctp-subtext0"> <p className="text-xs leading-relaxed text-ctp-subtext0">
Typeset subtitles karaoke openings, animated signs are authored as one event per Karaoke openings and animated signs are typeset as one subtitle event per animation
animation frame, and older versions counted every frame as its own line. This finds frame, and older versions counted every frame as a line. This collapses those runs back
those runs and collapses each one back to a single line, giving back the word and kanji to one line and drops the word and kanji counts they added. Repeated dialogue is left
counts they inflated. Ordinary repeated dialogue is left alone. alone.
</p> </p>
<div> <div>
@@ -192,8 +192,8 @@ export function DuplicateLineCleanup({ onClose, onCleaned }: DuplicateLineCleanu
</button> </button>
</div> </div>
<p className="text-[11px] text-ctp-overlay1"> <p className="text-[11px] text-ctp-overlay1">
Scan first: cleanup removes rows and cannot be undone. Session watch time and lines-seen Scan first: cleanup deletes rows and can't be undone. Watch time and lines-seen totals
totals are left untouched. stay as they are.
</p> </p>
</div> </div>
</div> </div>
@@ -6,11 +6,10 @@ import { KanjiBreakdown } from './KanjiBreakdown';
import { KanjiDetailPanel } from './KanjiDetailPanel'; import { KanjiDetailPanel } from './KanjiDetailPanel';
import { ExclusionManager } from './ExclusionManager'; import { ExclusionManager } from './ExclusionManager';
import { DuplicateLineCleanup } from './DuplicateLineCleanup'; import { DuplicateLineCleanup } from './DuplicateLineCleanup';
import { formatNumber } from '../../lib/formatters'; import { epochDayToDate, formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart'; import { TrendChart } from '../trends/TrendChart';
import { FrequencyRankTable } from './FrequencyRankTable'; import { FrequencyRankTable } from './FrequencyRankTable';
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable'; import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import { buildVocabularySummary } from '../../lib/dashboard-data';
import type { ExcludedWord } from '../../hooks/useExcludedWords'; import type { ExcludedWord } from '../../hooks/useExcludedWords';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats'; import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
@@ -35,7 +34,7 @@ export function VocabularyTab({
onRemoveExclusion, onRemoveExclusion,
onClearExclusions, onClearExclusions,
}: VocabularyTabProps) { }: VocabularyTabProps) {
const { words, kanji, knownWords, loading, error, reload } = useVocabulary(); const { words, kanji, knownWords, summary, charts, loading, error, reload } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null); const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [hideNames, setHideNames] = useState(false); const [hideNames, setHideNames] = useState(false);
const [showExclusionManager, setShowExclusionManager] = useState(false); const [showExclusionManager, setShowExclusionManager] = useState(false);
@@ -48,19 +47,26 @@ export function VocabularyTab({
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w)); if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
return result; return result;
}, [words, hideNames, excluded, isExcluded]); }, [words, hideNames, excluded, isExcluded]);
const summary = useMemo( const chartData = useMemo(
() => buildVocabularySummary(filteredWords, kanji), () => ({
[filteredWords, kanji], topWords:
((hideNames ? charts?.topWordsWithoutNames : charts?.topWords) ?? []).map((word) => ({
label: word.headword,
value: word.frequency,
})) ?? [],
newWordsTimeline:
((hideNames ? charts?.newWordsTimelineWithoutNames : charts?.newWordsTimeline) ?? []).map(
(point) => ({
label: epochDayToDate(point.epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
value: point.wordCount,
}),
) ?? [],
}),
[charts, hideNames],
); );
const knownWordCount = useMemo(() => {
if (knownWords.size === 0) return 0;
let count = 0;
for (const w of filteredWords) {
if (knownWords.has(w.headword)) count += 1;
}
return count;
}, [filteredWords, knownWords]);
if (loading) { if (loading) {
return ( return (
@@ -82,7 +88,9 @@ export function VocabularyTab({
}; };
const handleBarClick = (headword: string): void => { const handleBarClick = (headword: string): void => {
const match = filteredWords.find((w) => w.headword === headword); const match = (hideNames ? charts?.topWordsWithoutNames : charts?.topWords)?.find(
(word) => word.headword === headword,
);
if (match) onOpenWordDetail?.(match.wordId); if (match) onOpenWordDetail?.(match.wordId);
}; };
@@ -90,29 +98,43 @@ export function VocabularyTab({
setSelectedKanjiId(entry.kanjiId); setSelectedKanjiId(entry.kanjiId);
}; };
const displayedSummary = hideNames
? {
uniqueWords: summary?.uniqueWordsWithoutNames ?? 0,
newThisWeek: summary?.newThisWeekWithoutNames ?? 0,
knownWordCount: summary?.knownWordCountWithoutNames ?? null,
}
: {
uniqueWords: summary?.uniqueWords ?? 0,
newThisWeek: summary?.newThisWeek ?? 0,
knownWordCount: summary?.knownWordCount ?? null,
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3"> <div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
<StatCard <StatCard
label="Unique Words" label="Unique Words"
value={formatNumber(summary.uniqueWords)} value={summary ? formatNumber(displayedSummary.uniqueWords) : '…'}
color="text-ctp-blue" color="text-ctp-blue"
/> />
{knownWords.size > 0 && ( {displayedSummary.knownWordCount !== null ? (
<StatCard <StatCard
label="Known Words" label="Known Words"
value={`${formatNumber(knownWordCount)} (${summary.uniqueWords > 0 ? Math.round((knownWordCount / summary.uniqueWords) * 100) : 0}%)`} value={`${formatNumber(displayedSummary.knownWordCount)} (${displayedSummary.uniqueWords > 0 ? Math.round((displayedSummary.knownWordCount / displayedSummary.uniqueWords) * 100) : 0}%)`}
color="text-ctp-green" color="text-ctp-green"
/> />
)} ) : knownWords.size > 0 ? (
<StatCard label="Known Words" value="…" color="text-ctp-green" />
) : null}
<StatCard <StatCard
label="Unique Kanji" label="Unique Kanji"
value={formatNumber(summary.uniqueKanji)} value={summary ? formatNumber(summary.uniqueKanji) : '…'}
color="text-ctp-teal" color="text-ctp-teal"
/> />
<StatCard <StatCard
label="New This Week" label="New This Week"
value={`+${formatNumber(summary.newThisWeek)}`} value={summary ? `+${formatNumber(displayedSummary.newThisWeek)}` : '…'}
color="text-ctp-mauve" color="text-ctp-mauve"
/> />
</div> </div>
@@ -154,19 +176,25 @@ export function VocabularyTab({
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<TrendChart <TrendChart
title="Top Repeated Words" title="Top Repeated Words"
data={summary.topWords} data={chartData.topWords}
color="#8aadf4" color="#8aadf4"
type="bar" type="bar"
onBarClick={handleBarClick} onBarClick={handleBarClick}
/> />
<TrendChart <TrendChart
title="New Words by Day" title="New Words by Day"
data={summary.newWordsTimeline} data={chartData.newWordsTimeline}
color="#c6a0f6" color="#c6a0f6"
type="line" type="line"
/> />
</div> </div>
{charts && !charts.ready && (
<p className="text-xs text-ctp-overlay1" role="status">
Building vocabulary history in the background
</p>
)}
<FrequencyRankTable <FrequencyRankTable
words={filteredWords} words={filteredWords}
knownWords={knownWords} knownWords={knownWords}
+37 -2
View File
@@ -1,11 +1,18 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getStatsClient } from './useStatsApi'; import { getStatsClient } from './useStatsApi';
import type { VocabularyEntry, KanjiEntry } from '../types/stats'; import type {
VocabularyEntry,
KanjiEntry,
StatsVocabularyCharts,
StatsVocabularySummary,
} from '../types/stats';
export function useVocabulary() { export function useVocabulary() {
const [words, setWords] = useState<VocabularyEntry[]>([]); const [words, setWords] = useState<VocabularyEntry[]>([]);
const [kanji, setKanji] = useState<KanjiEntry[]>([]); const [kanji, setKanji] = useState<KanjiEntry[]>([]);
const [knownWords, setKnownWords] = useState<Set<string>>(new Set()); const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
const [summary, setSummary] = useState<StatsVocabularySummary | null>(null);
const [charts, setCharts] = useState<StatsVocabularyCharts | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Bumped by `reload` after maintenance rewrites the vocabulary tables. // Bumped by `reload` after maintenance rewrites the vocabulary tables.
@@ -16,6 +23,8 @@ export function useVocabulary() {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError(null); setError(null);
setSummary(null);
setCharts(null);
const client = getStatsClient(); const client = getStatsClient();
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()]) Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
.then(([wordsResult, kanjiResult, knownResult]) => { .then(([wordsResult, kanjiResult, knownResult]) => {
@@ -46,10 +55,36 @@ export function useVocabulary() {
if (cancelled) return; if (cancelled) return;
setLoading(false); setLoading(false);
}); });
void client
.getVocabularySummary()
.then((nextSummary) => {
if (!cancelled) setSummary(nextSummary);
})
.catch((summaryError: unknown) => {
console.error('Failed to load vocabulary summary', summaryError);
});
let chartRetryTimer: ReturnType<typeof setTimeout> | null = null;
const loadCharts = (): void => {
void client
.getVocabularyCharts()
.then((nextCharts) => {
if (cancelled) return;
setCharts(nextCharts);
if (!nextCharts.ready) {
chartRetryTimer = setTimeout(loadCharts, 1_000);
}
})
.catch((chartError: unknown) => {
console.error('Failed to load vocabulary charts', chartError);
if (!cancelled) chartRetryTimer = setTimeout(loadCharts, 1_000);
});
};
loadCharts();
return () => { return () => {
cancelled = true; cancelled = true;
if (chartRetryTimer) clearTimeout(chartRetryTimer);
}; };
}, [reloadToken]); }, [reloadToken]);
return { words, kanji, knownWords, loading, error, reload }; return { words, kanji, knownWords, summary, charts, loading, error, reload };
} }
+2
View File
@@ -100,6 +100,8 @@ export const apiClient = {
getSessionKnownWordsTimeline: (id: number) => getSessionKnownWordsTimeline: (id: number) =>
fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`), fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`),
getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`), getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`),
getVocabularySummary: () => fetchJson('vocabularySummary', '/api/stats/vocabulary/summary'),
getVocabularyCharts: () => fetchJson('vocabularyCharts', '/api/stats/vocabulary/charts'),
getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'), getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => { setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
await fetchResponse('/api/stats/excluded-words', { await fetchResponse('/api/stats/excluded-words', {
+19 -1
View File
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters'; import {
epochDayToDate,
epochMsFromDbTimestamp,
formatRelativeDate,
formatSessionDayLabel,
} from './formatters';
const FIXED_NOW = new Date(2026, 2, 16, 12, 0, 0).getTime(); const FIXED_NOW = new Date(2026, 2, 16, 12, 0, 0).getTime();
@@ -108,6 +113,19 @@ test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => {
assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000); assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000);
}); });
test('epochDayToDate preserves the calendar day west of UTC', () => {
const previousTimezone = process.env.TZ;
process.env.TZ = 'America/Los_Angeles';
try {
const epochDay = Math.floor(Date.UTC(2026, 2, 16) / 86_400_000);
const date = epochDayToDate(epochDay);
assert.deepEqual([date.getFullYear(), date.getMonth(), date.getDate()], [2026, 2, 16]);
} finally {
if (previousTimezone === undefined) delete process.env.TZ;
else process.env.TZ = previousTimezone;
}
});
test('formatSessionDayLabel formats today and yesterday', () => { test('formatSessionDayLabel formats today and yesterday', () => {
withFixedNow((now) => { withFixedNow((now) => {
const oneDayMs = 24 * 60 * 60_000; const oneDayMs = 24 * 60 * 60_000;
+2 -1
View File
@@ -38,7 +38,8 @@ export function formatRelativeDate(ms: number): string {
} }
export function epochDayToDate(epochDay: number): Date { export function epochDayToDate(epochDay: number): Date {
return new Date(epochDay * 86_400_000); const utcDate = new Date(epochDay * 86_400_000);
return new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate());
} }
export function localDayFromMs(ms: number): number { export function localDayFromMs(ms: number): number {
+17 -3
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
const VOCABULARY_TAB_PATH = fileURLToPath( const VOCABULARY_TAB_PATH = fileURLToPath(
new URL('../components/vocabulary/VocabularyTab.tsx', import.meta.url), new URL('../components/vocabulary/VocabularyTab.tsx', import.meta.url),
); );
const VOCABULARY_HOOK_PATH = fileURLToPath(new URL('../hooks/useVocabulary.ts', import.meta.url));
test('VocabularyTab declares all hooks before loading and error early returns', () => { test('VocabularyTab declares all hooks before loading and error early returns', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8'); const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
@@ -20,15 +21,28 @@ test('VocabularyTab declares all hooks before loading and error early returns',
assert.deepEqual(hooksAfterLoadingGuard ?? [], []); assert.deepEqual(hooksAfterLoadingGuard ?? [], []);
}); });
test('VocabularyTab memoizes summary and known-word aggregate calculations', () => { test('VocabularyTab uses uncapped server-side data for its charts and card totals', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8'); const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
assert.match( assert.match(
source, source,
/const summary = useMemo\([\s\S]*buildVocabularySummary\(filteredWords, kanji\)[\s\S]*\[filteredWords, kanji\][\s\S]*\);/, /const \{ words, kanji, knownWords, summary, charts, loading, error, reload \} = useVocabulary\(\);/,
); );
assert.match(source, /charts\?\.topWordsWithoutNames/);
assert.match(source, /charts\?\.newWordsTimelineWithoutNames/);
assert.doesNotMatch(source, /buildVocabularySummary\(/);
assert.match(source, /uniqueWords: summary\?\.uniqueWordsWithoutNames \?\? 0/);
assert.match(source, /uniqueWords: summary\?\.uniqueWords \?\? 0/);
assert.match(source, /value=\{summary \? formatNumber\(summary\.uniqueKanji\) : '…'\}/);
});
test('useVocabulary loads exact card totals without holding up the vocabulary tables', () => {
const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8');
assert.match( assert.match(
source, source,
/const knownWordCount = useMemo\(\(\) => \{[\s\S]*for \(const w of filteredWords\) \{[\s\S]*knownWords\.has\(w\.headword\)[\s\S]*\}\s*return count;\s*\}, \[filteredWords, knownWords\]\);/, /Promise\.allSettled\(\[\s*client\.getVocabulary\(500\),\s*client\.getKanji\(200\),\s*client\.getKnownWords\(\),?\s*\]\)/,
); );
assert.match(source, /void client\s*\.getVocabularySummary\(\)\s*\.then\(/);
assert.match(source, /client\s*\.getVocabularyCharts\(\)/);
}); });