Compare commits

..
Author SHA1 Message Date
sudacode 354e3ea2e5 test(dictionary): pin concurrent snapshot writers to one surviving snapshot
Give each concurrent writer a distinct title, length, and term text so the
assertion identifies which writer's snapshot survived instead of only
checking that the file parses with the expected entry count.
2026-08-17 11:10:38 -07:00
sudacode 9df2f37d4b fix(dictionary): isolate concurrent snapshot writes and shorten zip build blocks
The streamed snapshot writer keyed its temp file on the pid alone, so two
overlapping writes for the same media (a manual generate racing auto-sync)
streamed into one file and tore it; add a per-write sequence suffix.

Term banks were stringified 10k entries at a time, measured at ~38MB and
~135ms per bank on a real merged dictionary. Halving that block matters
more than the write itself: at 2k entries the longest event-loop stall in
a merged build drops from ~135ms to 28ms.
2026-08-17 02:46:27 -07:00
sudacode 1228ebe622 fix(dictionary): stop character dictionary IO from blocking the main process
Generating and importing a large character dictionary froze the whole
app long enough for the compositor to raise its application-not-
responding dialog over the player. Multi-hundred-MB snapshot JSONs and
the merged archive were read, written, and zipped synchronously on the
main process, and the character image / name-candidate caches re-read
every cached snapshot synchronously inside a lookup whenever the
snapshot directory changed.

- snapshot reads/writes are async; writes stream in slices and rename
  into place so a crash or concurrent writer cannot tear a snapshot
- buildDictionaryZip yields between ~8MB slices and CRC32 uses the
  native zlib implementation
- the image and name-candidate lookup caches rebuild in the background
  and serve the previous index while the rebuild runs

Worst main-thread stall over a 1.4GB snapshot set drops from 8s+ to
under 700ms.
2026-08-17 01:45:54 -07:00
sudacode ec7f0345b0 fix(notifications): spawn notify-send without AppImage library overrides
Electron AppImages export LD_LIBRARY_PATH pointing at bundled libraries
whose stale libnotify kills the system notify-send with a symbol lookup
error, permanently disabling in-place replacement and forcing the
flickering Electron close-and-reopen fallback. Drop the override from
the child environment so the system binary resolves its own libraries.
2026-08-17 01:45:47 -07:00
sudacode 00b1b79bf4 fix(mpv): recover from stalled IPC connects (#204) 2026-08-16 22:58:28 -07:00
sudacode e11a5fea0d chore(release): prepare v0.19.4-beta.1 2026-08-16 02:00:32 -07:00
sudacode f73fe179d0 fix(docs): keep versioned pages out of search indexes
- Add self-canonical noindex signals and headers for archived docs
- Restore sitemap lastmod dates from the tracked checkout
2026-08-16 01:45:08 -07:00
sudacode 2938e7a32a fix(overlay): prevent Windows mouse lag during click-through tracking (#201) 2026-08-16 01:34:51 -07:00
sudacode 82f6b4705a fix(overlay): recycle Windows modal windows after close
- Refresh the hidden modal renderer between Windows sessions
- Add regression coverage and stabilize launcher completion testing
2026-08-16 01:10:07 -07:00
sudacode a02c33dac4 fix(overlay): keep macOS modal windows on fullscreen Spaces (#200) 2026-08-15 21:43:26 -07:00
96 changed files with 1913 additions and 2765 deletions
@@ -0,0 +1,5 @@
type: fixed
area: dictionary
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app (and trigger the compositor's "application not responding" dialog) on large dictionaries; snapshot reads/writes, archive building, and the character image/name lookup caches now do their heavy work off the UI's critical path.
- Desktop progress notifications now update in place on Linux AppImage installs too: the AppImage's bundled libraries broke the system notify-send helper, which silently forced the flickering close-and-reopen notification fallback.
+5
View File
@@ -0,0 +1,5 @@
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.
@@ -0,0 +1,5 @@
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
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed the overlay getting stuck on "Overlay loading" forever when startup stalls: mpv IPC connection attempts now time out and retry, switching sockets aborts obsolete attempts, and the plugin replaces its spinner with an actionable error if overlay content is still not ready after 30 seconds.
+4
View File
@@ -0,0 +1,4 @@
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.
@@ -1,8 +0,0 @@
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; playback writes queue safely during the one-time rebuild and resume afterward.
- Calendar-day chart labels now preserve the recorded local date in time zones west of UTC.
- Vocabulary summary cards and charts refresh automatically after the word exclusion list changes, and failed loads retry with backoff before showing an inline error with a Retry control.
- Rapid exclusion edits no longer race each other; writes are sent in order so a slower earlier save cannot overwrite a newer list.
+49 -14
View File
@@ -1,3 +1,4 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { extname, join, posix, resolve, sep } from 'node:path';
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
@@ -26,6 +27,9 @@ function optionalEnv(value: string | undefined): string | undefined {
const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
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 docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
@@ -82,15 +86,18 @@ function pageToRoute(page: string): string | null {
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 {
const route = pageToRoute(page);
if (!route) return null;
if (channel === 'main') {
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
}
if (channel === 'stable-archive' && docsVersion !== latestStable) {
if (!isIndexableChannel) {
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
}
@@ -106,7 +113,9 @@ function transformPageHead({ page }: TransformContext): HeadConfig[] {
const href = pageToCanonicalHref(page);
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
if (channel === 'main') {
// Crawlable so links still pass through, but out of the index: ~30 archived copies
// of every page otherwise soak up the crawl budget the current docs need.
if (!isIndexableChannel) {
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
}
@@ -287,6 +296,39 @@ 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[] = [
{ text: 'Home', link: '/' },
{ text: 'Get Started', link: '/installation' },
@@ -419,14 +461,7 @@ const config: UserConfig = {
appearance: 'dark',
cleanUrls: true,
metaChunk: true,
sitemap: {
hostname: DOCS_HOSTNAME,
transformItems(items) {
return items.filter(
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
);
},
},
sitemap,
transformHead: transformPageHead,
lastUpdated: true,
srcExclude: ['subagents/**', 'README.md'],
+3 -1
View File
@@ -38,8 +38,10 @@ bun run docs:dev
The public docs root is stable-only:
- `/` serves the latest stable release docs.
- `/main/` serves development docs from `main` and is marked `noindex,follow`.
- `/main/` serves development docs from `main`.
- `/v/<version>/` serves stable release archives.
- 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.
+1 -3
View File
@@ -82,7 +82,7 @@ Expandable session history with new-word activity, cumulative totals, and pause/
#### Vocabulary
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 cards and charts also refresh automatically after the word exclusion list changes. 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.
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.
![Stats Vocabulary](/screenshots/stats-vocabulary.png)
@@ -180,7 +180,6 @@ 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.
- 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.
- New-word charts use their own permanent lexical daily rollups, which are not pruned by activity-rollup retention.
## Storage / Performance Model
@@ -350,7 +349,6 @@ Rollup tables:
- `imm_daily_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
Vocabulary tables:
+49 -21
View File
@@ -56,34 +56,43 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
]);
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
expect(mainDocsConfig.sitemap).toBeUndefined();
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
});
test('latest stable archive canonical points to root equivalent', async () => {
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
test.each([
['latest stable', 'v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'],
['superseded', 'v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'],
])(
'%s archive keeps a self-referential canonical and stays out of the index',
async (_label, version, base, expectedCanonical) => {
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
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 latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
const head = await archiveConfig.transformHead?.(makeTransformContext('usage.md'));
expect(head).toContainEqual([
'link',
{ rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
]);
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
});
expect(head).toContainEqual(['link', { rel: 'canonical', href: expectedCanonical }]);
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
// A sitemap here would advertise the archive tree we just excluded.
expect(archiveConfig.sitemap).toBeUndefined();
} finally {
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
}
},
);
test('stable archive theme links stay on the selected version', async () => {
const previousCwd = process.cwd();
@@ -433,3 +442,22 @@ test('docs sitemap excludes duplicate README page from indexable URLs', async ()
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,7 +129,11 @@ 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
overlay window remained mapped above mpv.
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
the Linux cursor-poll fallback, not bounding-shape clipping.
the Linux cursor-poll fallback, not bounding-shape clipping. Note that on Windows click-through
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
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
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.3",
"version": "0.19.4-beta.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+5 -2
View File
@@ -106,8 +106,11 @@ function M.create(ctx)
local function get_subtitle_ass_property()
local ass_text = mp.get_property("sub-text/ass")
if type(ass_text) == "string" and ass_text ~= "" then
return ass_text
if ass_text ~= nil then
if type(ass_text) == "string" and ass_text ~= "" then
return ass_text
end
return nil
end
ass_text = mp.get_property("sub-text-ass")
if type(ass_text) == "string" and ass_text ~= "" then
+26
View File
@@ -7,6 +7,8 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading "
local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" }
local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18
local OVERLAY_LOADING_OSD_DEADLINE_SECONDS = 30
local OVERLAY_LOADING_OSD_TIMEOUT_MESSAGE = "Overlay did not become ready; check SubMiner logs"
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30
@@ -265,10 +267,19 @@ function M.create(ctx)
state.overlay_loading_osd_timer = nil
end
local function clear_overlay_loading_osd_deadline()
local timeout = state.overlay_loading_osd_deadline
if timeout and timeout.kill then
timeout:kill()
end
state.overlay_loading_osd_deadline = nil
end
local function stop_overlay_loading_osd()
state.overlay_loading_osd_active = false
state.overlay_loading_osd_frame = 1
clear_overlay_loading_osd_timer()
clear_overlay_loading_osd_deadline()
end
local function start_overlay_loading_osd()
@@ -291,6 +302,21 @@ function M.create(ctx)
end
end)
end
if type(mp.add_timeout) == "function" then
state.overlay_loading_osd_deadline = mp.add_timeout(OVERLAY_LOADING_OSD_DEADLINE_SECONDS, function()
if not state.overlay_loading_osd_active then
return
end
state.overlay_loading_osd_deadline = nil
stop_overlay_loading_osd()
subminer_log(
"warn",
"process",
"Overlay loading deadline expired before the app reported content ready"
)
show_osd(OVERLAY_LOADING_OSD_TIMEOUT_MESSAGE, { force = true })
end)
end
end
local function disarm_auto_play_ready_gate(options)
+3 -3
View File
@@ -232,7 +232,7 @@ function M.create(ctx)
elseif action_id == "triggerFieldGrouping" then
return { "--trigger-field-grouping" }
elseif action_id == "triggerSubsync" then
return { "--trigger-subsync" }
return { "--session-action", '{"actionId":"triggerSubsync"}' }
elseif action_id == "mineSentence" then
return { "--mine-sentence" }
elseif action_id == "mineSentenceMultiple" then
@@ -251,7 +251,7 @@ function M.create(ctx)
elseif action_id == "markWatched" then
return { "--mark-watched" }
elseif action_id == "openRuntimeOptions" then
return { "--open-runtime-options" }
return { "--session-action", '{"actionId":"openRuntimeOptions"}' }
elseif action_id == "openJimaku" then
return { "--open-jimaku" }
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
@@ -259,7 +259,7 @@ function M.create(ctx)
elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" }
elseif action_id == "openSessionHelp" then
return { "--open-session-help" }
return { "--session-action", '{"actionId":"openSessionHelp"}' }
elseif action_id == "openCharacterDictionaryManager" then
return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
elseif action_id == "openControllerSelect" then
+1
View File
@@ -26,6 +26,7 @@ function M.new()
auto_play_ready_initial_pause_ownership_consumed = false,
overlay_loading_osd_active = false,
overlay_loading_osd_timer = nil,
overlay_loading_osd_deadline = nil,
overlay_loading_osd_frame = 1,
pending_visible_overlay_hide_timer = nil,
pending_visible_overlay_hide_generation = 0,
+13 -1
View File
@@ -4,6 +4,7 @@ function M.create(ctx)
local mp = ctx.mp
local input = ctx.input
local process = ctx.process
local state = ctx.state
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
@@ -93,7 +94,18 @@ function M.create(ctx)
if not ensure_binary_for_menu() then
return
end
process.run_control_command_async("open-session-help")
process.run_binary_command_async({
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
+38 -66
View File
@@ -1,80 +1,52 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-base-version: 0.19.0 -->
<!-- prerelease-base-version: 0.19.4 -->
## Highlights
### Added
- **Sync Stats & History**
- New **Sync Stats & History** window (tray menu) and `subminer sync <host>` command keep mining stats and watch history in sync between machines over SSH, with saved devices, per-host sync direction, and live stage-by-stage progress.
- Merges are safe to repeat: data combines without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
- Manual snapshot tools (create, merge, reveal, delete) and connection testing cover one-off transfers; Windows machines running the built-in OpenSSH Server work as sync remotes too, with no setup needed beyond SSH access. Power users can script transfers directly with `--push`/`--pull`, `--check`, `--snapshot`/`--merge`, and `--json` flags.
- **TsukiHime Subtitle Downloads**
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow.
- Press `Ctrl+Shift+T` to search by tabs for the primary and secondary languages; the matching release is found automatically from the video filename and loads straight into mpv, no API key required.
- **Post-Playback History Menu**
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
- The menu shown right after picking a series from `subminer -H` now offers the previous episode too, matching the post-playback menu.
- **Known-Word Highlighting by Anki Maturity**
- Subtitle highlights for known words can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Enable it with `ankiConnect.knownWords.maturityEnabled`, or toggle it live during a session.
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
- **Stats Library Entry Deletion**
- Added a "Delete Entry" action in the stats Library detail view that removes an entire title in one step: every episode, session, subtitle line, rollup, cover, and vocabulary count derived from it. Previously a mistaken entry had to be cleared episode by episode and still lingered in the Library.
- Delete progress (session, session group, episode, or full entry) now shows app-wide as a progress bar plus a status toast, staying visible across tabs and windows instead of disappearing when you switch away.
- Deletes are dramatically faster on large libraries, and opening the Vocabulary tab no longer stalls; the first launch after upgrading migrates the stats database in place to support this.
### Changed
- **Clipboard-Video Shortcut**
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of being fixed.
- **Library Merge and Move**
- Duplicate library cards for the same show can now be combined: select cards in the library grid, choose "Merge Selected," and pick which entry to keep. Sessions, mined cards, and watch time move over, and future episodes stay matched to the merged card.
- Episodes can be reassigned to a different library entry with a "→" button on the episode row, fixing cases where a file lands under the wrong title. Manual assignments survive later filename parsing, Jellyfin refreshes, and season repair.
- Exact AniList matches with compatible seasons now merge automatically, while fuzzy matches show up as a dismissible "Possible duplicate" prompt instead of merging without confirmation.
### Fixed
- **Anki Audio Generation on Network Drives**
- Fixed sentence-audio generation timing out on slow network-mounted video files with many subtitle and font-attachment streams.
- Extraction now uses bounded probing and a two-minute budget, and failures show a clear error instead of a cryptic one.
- **Duplicate Subtitle Line Stats**
- Fixed karaoke openings and animated signs (which record one subtitle event per animation frame) inflating word and kanji counts and skewing "Top Repeated Words." Ordinary repeated dialogue and rewatches are unaffected.
- Already-inflated stats can be cleaned up with the new "Duplicates" button in the Vocabulary tab, or `subminer stats cleanup --duplicate-lines` (supports `--dry-run` and `--lookback-days`). Only the affected subtitle lines and vocabulary counts are touched; watch time and lines-seen totals are untouched.
- **Overlay Modals on macOS and Windows**
- Fixed overlay modals and the stats window opening on the wrong macOS Space, or forcing a Space switch, when mpv is fullscreen. They now open above fullscreen mpv on its current Space.
- Modals are now prewarmed on macOS and Windows so shortcuts open them promptly, and Windows keeps the hidden modal responsive between sessions.
- **Wayland File Drag-and-Drop**
- Fixed dragging subtitle and video files from file managers like Thunar onto the overlay on native Wayland; dropped files are now resolved and sent to mpv.
- **Windows Mouse Lag**
- Fixed system-wide mouse lag while SubMiner is running on Windows, caused by a global mouse hook and blocking window lookups during click-through tracking.
- **Mining Clip Accuracy**
- Fixed mined audio and animated image clips sometimes capturing the wrong subtitle line when audio extraction was slow. The clip range is now locked in at the moment of lookup, so audio and image clips always match.
- **Linux Notifications**
- Character dictionary progress notifications on Linux now update in place instead of flickering off and back on with every status change.
- **Stats Delete Performance**
- Fixed stats deletes freezing the dashboard; deletes now reliably run off the main thread, with automatic retry if the delete worker crashes.
- Deletes, library merges/moves, and AniList reassignments are now much faster because totals are updated incrementally instead of rebuilt from scratch, and no longer erase lifetime totals older than the recent session history.
- Session deletes on large libraries dropped from minutes to milliseconds.
- **Word Highlighting Accuracy**
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
- **AniList Season Resolution**
- Season 2 and later episodes now resolve to the correct AniList entry by walking sequel relations instead of guessing from the title, so watch progress, the character dictionary, and cover art for later seasons no longer silently fall back to season 1.
- Manual AniList overrides now stay in effect for every episode in the same season (by folder and detected season), and setting an override now fixes both the character dictionary and AniList watch progress together instead of needing separate corrections.
- **Startup Playback Pausing Too Early**
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
- **Linux AppImage Crash Notification on Quit**
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- If needed, the mount-keepalive behavior behind this fix can be disabled with `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1`.
- **AnkiConnect Proxy Port Conflict**
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
- **Stats & Settings Reliability**
- Fixed session stats reporting zero known words after the known-word cache gained maturity tiers.
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
- **Stats Library Cover After Relink**
- Relinking a title to a different AniList entry now updates its cover art in the stats Library grid, not just the detail view, so unrelated titles no longer end up sharing the wrong cover.
- **Rofi Menu Prompt Spacing**
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
### Docs
- **Feature Demos Page**
- Hidden the unfinished feature demos page from the documentation sidebar; it's still reachable by direct URL.
## What's Changed
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
- Anki maturity-based known-word highlighting by @ksyasuda in #172
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
## Installation
+13
View File
@@ -35,6 +35,17 @@ const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache');
const maxCloudflareFiles = 20_000;
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(
command: string,
args: string[],
@@ -173,6 +184,7 @@ function buildDocs(options: {
SUBMINER_DOCS_BASE: options.base,
SUBMINER_DOCS_OUT_DIR: options.outDir,
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
SUBMINER_DOCS_REPO_DIR: currentDocsSite,
SUBMINER_DOCS_CHANNEL: options.channel,
SUBMINER_DOCS_VERSION: options.version ?? '',
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
@@ -378,6 +390,7 @@ function main() {
});
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(join(aggregateOutDir, '_headers'), deployHeaders);
assertCloudflarePagesLimits(aggregateOutDir);
const prunedArchives = pruneArchiveCacheGenerations({
cacheRoot: archiveCacheRoot,
+53 -1
View File
@@ -130,7 +130,9 @@ local function run_plugin_scenario(config)
function mp.add_timeout(seconds, callback)
recorded.timeouts[#recorded.timeouts + 1] = seconds
local delay = tonumber(seconds) or 0
local timeout = {
seconds = delay,
killed = false,
callback = callback,
}
@@ -138,7 +140,6 @@ local function run_plugin_scenario(config)
self.killed = true
end
local delay = tonumber(seconds) or 0
if callback and delay < 5 and not config.defer_timeouts then
callback()
end
@@ -514,6 +515,15 @@ local function has_timeout(timeouts, target)
return false
end
local function find_timeout_handle(recorded, target)
for _, timeout in ipairs(recorded.timeout_handles) do
if math.abs(timeout.seconds - target) < 0.0001 then
return timeout
end
end
return nil
end
local function env_has(call, target)
local env = (call and call.env) or {}
for _, value in ipairs(env) do
@@ -1636,6 +1646,8 @@ do
#recorded.periodic_timers == 1,
"auto-start visible overlay should refresh the early overlay loading OSD"
)
local overlay_loading_deadline = find_timeout_handle(recorded, 30)
assert_true(overlay_loading_deadline ~= nil, "overlay loading OSD should have a bounded deadline")
local overlay_loading_timer = recorded.periodic_timers[1]
recorded.periodic_timers[1].callback()
assert_true(
@@ -1670,6 +1682,46 @@ do
recorded.periodic_timers[1].killed == true,
"overlay loading ready should stop the early overlay loading OSD refresher"
)
assert_true(
overlay_loading_deadline.killed == true,
"overlay loading ready should cancel the bounded loading deadline"
)
end
do
local recorded, err = run_plugin_scenario({
defer_timeouts = true,
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
osd_messages = false,
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for overlay loading deadline scenario: " .. tostring(err))
fire_event(recorded, "start-file")
local overlay_loading_deadline = find_timeout_handle(recorded, 30)
assert_true(overlay_loading_deadline ~= nil, "overlay loading deadline should be scheduled")
overlay_loading_deadline.callback()
assert_true(
recorded.periodic_timers[1].killed == true,
"overlay loading deadline should stop the loading spinner"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Overlay did not become ready; check SubMiner logs"),
"overlay loading deadline should replace the spinner with actionable feedback"
)
assert_true(
has_log_containing(recorded.logs, "Overlay loading deadline expired"),
"overlay loading deadline should leave a diagnostic log entry"
)
end
do
@@ -284,22 +284,6 @@ function createMockTracker(
getSessionTimeline: async () => [],
getSessionEvents: async () => [],
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 () => [],
replaceStatsExcludedWords: async () => {},
getKanjiStats: async () => KANJI_STATS,
@@ -727,23 +711,6 @@ describe('stats server API routes', () => {
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 () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/kanji');
@@ -559,149 +559,6 @@ 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('tracker queues playback writes until lexical rollup backfill settles', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let startBackfill = (): void => {};
let releaseBackfill = (): void => {};
let markBackfillStarted = (): void => {};
const backfillStartGate = new Promise<void>((resolve) => {
startBackfill = resolve;
});
const heldBackfill = new Promise<void>((resolve) => {
releaseBackfill = resolve;
});
const backfillStarted = new Promise<void>((resolve) => {
markBackfillStarted = resolve;
});
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 (workerDbPath) => {
await backfillStartGate;
const workerDb = new Database(workerDbPath);
try {
workerDb.exec('BEGIN IMMEDIATE');
markBackfillStarted();
await heldBackfill;
workerDb.exec('COMMIT');
} catch (error) {
try {
workerDb.exec('ROLLBACK');
} catch {
// Preserve the original worker failure.
}
throw error;
} finally {
workerDb.close();
}
},
},
);
tracker.handleMediaChange('https://example.com/backfill-test.mp4', 'Backfill Test');
startBackfill();
await backfillStarted;
tracker.recordCardsMined(1);
const privateApi = tracker as unknown as {
db: DatabaseSync;
queue: unknown[];
flushNow: () => void;
writeLock: { locked: boolean };
};
assert.equal(privateApi.writeLock.locked, true);
privateApi.flushNow();
assert.ok(privateApi.queue.length > 0);
assert.equal(
(
privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as {
total: number;
}
).total,
0,
);
releaseBackfill();
await waitForCondition(() => privateApi.queue.length === 0);
assert.equal(
(
privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as {
total: number;
}
).total,
1,
);
} finally {
releaseBackfill();
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('startup backfills lifetime summaries when retained sessions exist but summary tables are empty', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -5052,63 +4909,3 @@ test('ensureAnimeCoverArt fetches art via the latest video of the anime', async
cleanupDbPath(dbPath);
}
});
test('getVocabularySummary coalesces concurrent requests into one worker task', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let taskRuns = 0;
let releaseTask: (() => void) | null = null;
const seenKnownWords: Array<ReadonlySet<string> | null> = [];
const summary = {
uniqueWords: 1,
uniqueWordsWithoutNames: 1,
uniqueKanji: 0,
newThisWeek: 0,
newThisWeekWithoutNames: 0,
knownWordCount: null,
knownWordCountWithoutNames: null,
};
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor(
{ dbPath },
{
runVocabularySummaryTask: async (_dbPath, knownWords) => {
taskRuns += 1;
seenKnownWords.push(knownWords);
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
return summary;
},
destroyVocabularySummaryRunner: () => {},
},
);
const first = tracker.getVocabularySummary(null);
const second = tracker.getVocabularySummary(new Set(['猫']));
await waitForCondition(() => releaseTask !== null);
let release = releaseTask as (() => void) | null;
assert.ok(release);
release();
assert.deepEqual(await first, summary);
assert.equal(await second, await first);
assert.equal(taskRuns, 1);
// The coalesced caller's known-words set must not replace the snapshot the
// in-flight scan already started with.
assert.deepEqual(seenKnownWords, [null]);
releaseTask = null;
const third = tracker.getVocabularySummary(null);
await waitForCondition(() => releaseTask !== null);
release = releaseTask as (() => void) | null;
assert.ok(release);
release();
assert.deepEqual(await third, summary);
assert.equal(taskRuns, 2);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
+5 -94
View File
@@ -58,7 +58,6 @@ import {
getSessionEvents,
getSimilarWords,
getStatsExcludedWords,
getVocabularyChartData,
getVocabularyStats,
replaceStatsExcludedWords,
searchSubtitleSentences,
@@ -97,12 +96,6 @@ import {
DeleteMaintenanceWorkerRuntime,
type RunDeleteMaintenanceTask,
} 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 {
cleanupDuplicateSubtitleLines,
@@ -192,7 +185,6 @@ import {
type StatsExcludedWordRow,
type StreakCalendarRow,
type VocabularyCleanupSummary,
type VocabularyStatsSummary,
type WatchTimePerAnimeRow,
type WordAnimeAppearanceRow,
type WordDetailRow,
@@ -413,18 +405,8 @@ export class ImmersionTrackerService {
private readonly monthlyRollupRetentionMs: number;
private readonly vacuumIntervalMs: number;
private readonly dbPath: string;
private readonly writeLock = {
locked: false,
reasons: new Set<'flush' | 'delete-maintenance' | 'lexical-rollup-backfill'>(),
};
private readonly writeLock = { locked: false };
private readonly destroyDeleteMaintenanceRunner: () => void;
private readonly runVocabularySummaryTask: (
knownWords: ReadonlySet<string> | null,
) => Promise<VocabularyStatsSummary>;
private vocabularySummaryInFlight: Promise<VocabularyStatsSummary> | null = null;
private readonly destroyVocabularySummaryRunner: () => void;
private readonly runLexicalRollupBackfillTask: () => Promise<void>;
private readonly destroyLexicalRollupBackfillRunner: () => void;
private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
@@ -452,10 +434,6 @@ export class ImmersionTrackerService {
dependencies: {
runDeleteMaintenanceTask?: RunDeleteMaintenanceTask;
destroyDeleteMaintenanceRunner?: () => void;
runVocabularySummaryTask?: RunVocabularySummaryTask;
destroyVocabularySummaryRunner?: () => void;
runLexicalRollupBackfillTask?: (dbPath: string) => Promise<void>;
destroyLexicalRollupBackfillRunner?: () => void;
} = {},
) {
this.dbPath = options.dbPath;
@@ -475,34 +453,13 @@ export class ImmersionTrackerService {
runTask: (task) => runDeleteMaintenanceTask(this.dbPath, task),
onBusy: () => {
this.requireWriteQueueDrained('delete maintenance');
this.setWriteLock('delete-maintenance', true);
this.writeLock.locked = true;
},
onIdle: () => {
this.setWriteLock('delete-maintenance', false);
this.writeLock.locked = false;
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);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
@@ -590,7 +547,6 @@ export class ImmersionTrackerService {
}
}
this.preparedStatements = createTrackerPreparedStatements(this.db);
if (!areLexicalDailyRollupsReady(this.db)) this.startLexicalRollupBackfill();
this.scheduleMaintenance();
this.scheduleFlush();
}
@@ -609,8 +565,6 @@ export class ImmersionTrackerService {
this.isDestroyed = true;
this.deleteMaintenanceScheduler.destroy();
this.destroyDeleteMaintenanceRunner();
this.destroyVocabularySummaryRunner();
this.destroyLexicalRollupBackfillRunner();
this.db.close();
}
@@ -680,24 +634,6 @@ export class ImmersionTrackerService {
return getVocabularyStats(this.db, limit, excludePos);
}
async getVocabularySummary(knownWords: ReadonlySet<string> | null) {
// Concurrent dashboard refreshes share one worker scan; the coalesced
// callers accept the first caller's known-words snapshot.
const inFlight = this.vocabularySummaryInFlight;
if (inFlight) return inFlight;
const task = this.runVocabularySummaryTask(knownWords);
this.vocabularySummaryInFlight = task;
try {
return await task;
} finally {
if (this.vocabularySummaryInFlight === task) this.vocabularySummaryInFlight = null;
}
}
async getVocabularyChartData() {
return getVocabularyChartData(this.db);
}
async getStatsExcludedWords(): Promise<StatsExcludedWordRow[]> {
return getStatsExcludedWords(this.db);
}
@@ -974,31 +910,6 @@ export class ImmersionTrackerService {
}
}
private setWriteLock(
reason: 'flush' | 'delete-maintenance' | 'lexical-rollup-backfill',
active: boolean,
): void {
if (active) this.writeLock.reasons.add(reason);
else this.writeLock.reasons.delete(reason);
this.writeLock.locked = this.writeLock.reasons.size > 0;
}
private startLexicalRollupBackfill(): void {
this.requireWriteQueueDrained('lexical rollup backfill');
this.setWriteLock('lexical-rollup-backfill', true);
void this.runLexicalRollupBackfillTask()
.catch((error: unknown) => {
this.logger.warn(
'Lexical daily rollup backfill failed; it will retry on next startup',
error,
);
})
.finally(() => {
this.setWriteLock('lexical-rollup-backfill', false);
if (!this.isDestroyed && this.queue.length > 0) this.scheduleFlush(0);
});
}
async reassignAnimeAnilist(
animeId: number,
info: {
@@ -2054,7 +1965,7 @@ export class ImmersionTrackerService {
}
const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length));
this.setWriteLock('flush', true);
this.writeLock.locked = true;
try {
this.db.exec('BEGIN IMMEDIATE');
for (const write of batch) {
@@ -2066,7 +1977,7 @@ export class ImmersionTrackerService {
this.queue.unshift(...batch);
this.logger.warn('Immersion tracker flush failed, retrying later', error as Error);
} finally {
this.setWriteLock('flush', false);
this.writeLock.locked = false;
this.flushScheduled = false;
if (this.queue.length > 0) {
this.scheduleFlush(this.flushIntervalMs);
@@ -31,7 +31,6 @@ import {
getKanjiOccurrences,
getSessionSummaries,
getVocabularyStats,
getVocabularySummary,
getKanjiStats,
getSessionEvents,
getSessionTimeline,
@@ -1876,115 +1875,6 @@ 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('getVocabularySummary counts identically across id-keyed scan batches', () => {
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)
`);
for (let index = 0; index < 5; index += 1) {
insertWord.run(`単語${index}`, `単語${index}`);
}
const fullScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000);
const batchedScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000, 2);
assert.equal(fullScan.uniqueWords, 5);
assert.deepEqual(batchedScan, fullScan);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -1,103 +0,0 @@
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();
}
});
@@ -1,109 +0,0 @@
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();
}
}
@@ -1,11 +0,0 @@
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) });
}
@@ -1,35 +0,0 @@
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 });
}
});
@@ -1,15 +0,0 @@
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();
}
}
@@ -1,173 +0,0 @@
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 });
}
});
@@ -1,178 +0,0 @@
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,37 +13,19 @@ import type {
SimilarWordRow,
StatsExcludedWordRow,
VocabularyStatsRow,
VocabularyStatsSummary,
WordAnimeAppearanceRow,
WordDetailRow,
WordOccurrenceRow,
} from './types';
import { fromDbTimestamp, toDbTimestamp } from './query-shared';
import { nowMs } from './time';
import {
areLexicalDailyRollupsReady,
getLexicalDailyRollups,
localEpochDaySql,
} from './lexical-rollups';
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
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 VOCABULARY_SUMMARY_SCAN_BATCH_SIZE = 5_000;
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
const SENTENCE_SEARCH_MAX_LIMIT = 100;
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 {
if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT;
const normalized = Math.floor(limit);
@@ -171,191 +153,6 @@ export function getVocabularyStats(
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(),
scanBatchSize: number = VOCABULARY_SUMMARY_SCAN_BATCH_SIZE,
): VocabularyStatsSummary {
// Visibility and exclusion rules live in JS, so rows are scanned in id-keyed
// batches to keep memory bounded on large vocabularies.
const scanStmt = 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
WHERE id > ?
ORDER BY id
LIMIT ?
`);
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,
};
let lastId = Number.MIN_SAFE_INTEGER;
for (;;) {
const words = scanStmt.all(lastId, scanBatchSize) as VocabularyStatsRow[];
if (words.length === 0) break;
lastId = words[words.length - 1]!.wordId;
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;
}
}
if (words.length < scanBatchSize) break;
}
return summary;
}
export function getStatsExcludedWords(db: DatabaseSync): StatsExcludedWordRow[] {
return db
.prepare(
@@ -13,7 +13,6 @@ import {
toDbTimestamp,
} from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
import { areLexicalDailyRollupsReady, getLexicalDailyRollups } from './lexical-rollups';
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
type TrendGroupBy = 'day' | 'month';
@@ -661,16 +660,6 @@ function buildNewWordsPerDay(
cutoffMs: string | null,
axis: number[] | null,
): 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 prepared = db.prepare(`
SELECT
@@ -702,18 +691,6 @@ function buildNewWordsPerMonth(
cutoffMs: string | null,
axis: number[] | null,
): 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 prepared = db.prepare(`
SELECT
@@ -4,7 +4,6 @@ import { parseMediaInfo } from '../../../jimaku/utils';
import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
import { toDbMs, toDbTimestamp } from './query-shared';
@@ -891,11 +890,11 @@ export function ensureSchema(db: DatabaseSync): void {
VALUES ('last_rollup_sample_ms', 0)
ON CONFLICT(state_key) DO NOTHING
`);
const currentVersion = db
.prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1')
.get() as { schema_version: number } | null;
if (currentVersion?.schema_version === SCHEMA_VERSION) {
ensureLexicalDailyRollupTables(db);
ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db);
ensureAnimeMergeTables(db);
@@ -1454,7 +1453,6 @@ export function ensureSchema(db: DatabaseSync): void {
migrateSessionEventTimestampsToText(db);
ensureLexicalDailyRollupTables(db);
ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db);
@@ -1587,12 +1585,6 @@ export function ensureSchema(db: DatabaseSync): void {
VALUES (${SCHEMA_VERSION}, ${toDbTimestamp(nowMs())})
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 {
+1 -11
View File
@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 22;
export const SCHEMA_VERSION = 21;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -306,16 +306,6 @@ export interface VocabularyStatsRow {
lastSeen: number;
}
export interface VocabularyStatsSummary {
uniqueWords: number;
uniqueWordsWithoutNames: number;
uniqueKanji: number;
newThisWeek: number;
newThisWeekWithoutNames: number;
knownWordCount: number | null;
knownWordCountWithoutNames: number | null;
}
export interface StatsExcludedWordRow {
headword: string;
word: string;
@@ -1,67 +0,0 @@
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();
}
});
@@ -1,125 +0,0 @@
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();
}
}
@@ -1,19 +0,0 @@
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) });
}
@@ -1,17 +0,0 @@
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();
}
}
+8 -1
View File
@@ -34,6 +34,7 @@ import {
parseSubsyncManualRunRequest,
parseYoutubePickerResolveRequest,
} from '../../shared/ipc/validators';
import { applyOverlayClickThrough } from './overlay-click-through';
const { ipcMain } = electron;
@@ -442,7 +443,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
// Route forwarding requests through the platform-aware helper so Windows never
// 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);
},
+2 -2
View File
@@ -53,7 +53,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'sub-scale-by-window',
'osd-height',
'osd-dimensions',
'sub-text-ass',
'sub-text/ass',
'sub-border-size',
'sub-shadow-offset',
'sub-ass-override',
@@ -74,7 +74,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
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,
},
{
+22
View File
@@ -129,6 +129,28 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
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 () => {
const { deps, state } = createDeps({
emitSubtitleTrackChange: (payload) => state.events.push(payload),
+1 -1
View File
@@ -248,7 +248,7 @@ export async function dispatchMpvProtocolMessage(
isOverlayVisible: overlayVisible,
});
deps.setCurrentSubText(nextSubText);
} else if (msg.name === 'sub-text-ass') {
} else if (msg.name === 'sub-text/ass' || msg.name === 'sub-text-ass') {
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
} else if (msg.name === 'sub-start') {
deps.setCurrentSubStart((msg.data as number) || 0);
+78 -1
View File
@@ -38,7 +38,15 @@ class ManualCloseSocket extends FakeSocket {
}
}
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
class HangingSocket extends FakeSocket {
override connect(path: string): void {
this.connectedPaths.push(path);
// Never emits 'connect', 'error', or 'close' on its own: models a named
// pipe dial that stalls indefinitely.
}
}
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
assert.equal(getMpvReconnectDelay(0, true), 1000);
@@ -232,6 +240,75 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
assert.deepEqual(events, []);
});
test('MpvSocketTransport aborts a hung connect after the timeout and allows a fresh dial', async () => {
const events: string[] = [];
const errors: Error[] = [];
const sockets: HangingSocket[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
connectTimeoutMs: 5,
onConnect: () => {
events.push('connect');
},
onData: () => {},
onError: (error) => {
events.push('error');
errors.push(error);
},
onClose: () => {
events.push('close');
},
socketFactory: () => {
const socket = new HangingSocket();
sockets.push(socket);
return socket as unknown as net.Socket;
},
});
transport.connect();
assert.equal(transport.isConnecting, true);
await wait(20);
assert.deepEqual(events, ['error', 'close']);
assert.match(errors[0]!.message, /connect timed out/);
assert.equal(sockets[0]!.destroyed, true);
assert.equal(transport.isConnecting, false);
assert.equal(transport.isConnected, false);
transport.connect();
assert.equal(transport.isConnecting, true);
assert.equal(sockets.length, 2);
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv.sock');
transport.shutdown();
});
test('MpvSocketTransport does not fire the connect timeout after a successful connect', async () => {
const events: string[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
connectTimeoutMs: 5,
onConnect: () => {
events.push('connect');
},
onData: () => {},
onError: () => {
events.push('error');
},
onClose: () => {
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
transport.connect();
await wait(20);
assert.deepEqual(events, ['connect']);
assert.equal(transport.isConnected, true);
});
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
const events: string[] = [];
const sockets: ManualCloseSocket[] = [];
+36
View File
@@ -62,6 +62,8 @@ interface MpvSocketTransportEvents {
onClose: () => void;
}
export const MPV_CONNECT_TIMEOUT_MS = 5000;
export interface MpvSocketTransportOptions {
socketPath: string;
onConnect: () => void;
@@ -69,13 +71,16 @@ export interface MpvSocketTransportOptions {
onError: (error: Error) => void;
onClose: () => void;
socketFactory?: () => net.Socket;
connectTimeoutMs?: number;
}
export class MpvSocketTransport {
private socketPath: string;
private readonly callbacks: MpvSocketTransportEvents;
private readonly socketFactory: () => net.Socket;
private readonly connectTimeoutMs: number;
private socketRef: net.Socket | null = null;
private connectTimer: ReturnType<typeof setTimeout> | null = null;
public socket: net.Socket | null = null;
public connected = false;
public connecting = false;
@@ -83,6 +88,7 @@ export class MpvSocketTransport {
constructor(options: MpvSocketTransportOptions) {
this.socketPath = options.socketPath;
this.socketFactory = options.socketFactory ?? (() => new net.Socket());
this.connectTimeoutMs = options.connectTimeoutMs ?? MPV_CONNECT_TIMEOUT_MS;
this.callbacks = {
onConnect: options.onConnect,
onData: options.onData,
@@ -91,6 +97,31 @@ export class MpvSocketTransport {
};
}
private clearConnectTimeout(): void {
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = null;
}
}
// A named-pipe/socket dial that neither connects nor errors would otherwise
// latch `connecting` forever and silently block every future connect().
private armConnectTimeout(socket: net.Socket): void {
this.clearConnectTimeout();
this.connectTimer = setTimeout(() => {
this.connectTimer = null;
if (this.socketRef !== socket || this.connected) return;
this.connecting = false;
this.callbacks.onError(
new Error(`MPV IPC connect timed out after ${this.connectTimeoutMs}ms: ${this.socketPath}`),
);
// Destroying the socket emits 'close', which drives the normal
// disconnect path (including reconnect scheduling) upstream.
socket.destroy();
}, this.connectTimeoutMs);
this.connectTimer.unref?.();
}
setSocketPath(socketPath: string): void {
this.socketPath = socketPath;
}
@@ -111,6 +142,7 @@ export class MpvSocketTransport {
socket.on('connect', () => {
if (this.socketRef !== socket) return;
this.clearConnectTimeout();
this.connected = true;
this.connecting = false;
this.callbacks.onConnect();
@@ -123,6 +155,7 @@ export class MpvSocketTransport {
socket.on('error', (error: Error) => {
if (this.socketRef !== socket) return;
this.clearConnectTimeout();
this.connected = false;
this.connecting = false;
this.callbacks.onError(error);
@@ -130,12 +163,14 @@ export class MpvSocketTransport {
socket.on('close', () => {
if (this.socketRef !== socket) return;
this.clearConnectTimeout();
this.connected = false;
this.connecting = false;
this.callbacks.onClose();
});
socket.connect(this.socketPath);
this.armConnectTimeout(socket);
}
send(payload: MpvSocketMessagePayload): boolean {
@@ -149,6 +184,7 @@ export class MpvSocketTransport {
}
shutdown(): void {
this.clearConnectTimeout();
const socket = this.socketRef;
this.socketRef = null;
this.socket = null;
+140
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
MpvIpcClient,
MpvIpcClientDeps,
@@ -23,6 +24,18 @@ function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClie
};
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for MPV retry connection');
}
await wait(10);
}
}
function captureWarnLogs(run: () => void): string[] {
const originalWarn = console.warn;
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
@@ -505,6 +518,17 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
(command as { command: unknown[] }).command[1] === 1 &&
(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(
(command) =>
Array.isArray((command as { command: unknown[] }).command) &&
@@ -514,6 +538,8 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
assert.equal(hasSecondaryVisibilityReset, true);
assert.equal(hasTrackSubscription, true);
assert.equal(hasAssSubtitleSubscription, true);
assert.equal(hasDeprecatedAssSubtitleProperty, false);
assert.equal(hasPathRequest, true);
});
@@ -743,3 +769,117 @@ test('MpvIpcClient playNextSubtitle still auto-pauses at end while already playi
assert.equal((client as any).pendingPauseAtSubEnd, true);
assert.deepEqual(commands, [{ command: ['sub-seek', 1] }]);
});
class HangingTestSocket extends EventEmitter {
public connectedPaths: string[] = [];
public destroyed = false;
connect(path: string): void {
this.connectedPaths.push(path);
// Never resolves: models a stalled named-pipe dial.
}
write(): boolean {
return true;
}
destroy(): void {
this.destroyed = true;
}
}
class RetryTestSocket extends EventEmitter {
public connectedPaths: string[] = [];
public destroyed = false;
constructor(private readonly shouldConnect: boolean) {
super();
}
connect(path: string): void {
this.connectedPaths.push(path);
if (this.shouldConnect) {
setTimeout(() => this.emit('connect'), 0);
}
}
write(): boolean {
return true;
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.emit('close');
}
}
test('MpvIpcClient automatically retries the same socket path after a connect timeout', async () => {
const sockets: RetryTestSocket[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
const client = new MpvIpcClient(
'/tmp/mpv.sock',
makeDeps({
connectTimeoutMs: 5,
getReconnectTimer: () => reconnectTimer,
setReconnectTimer: (timer) => {
reconnectTimer = timer;
},
socketFactory: () => {
const socket = new RetryTestSocket(sockets.length > 0);
sockets.push(socket);
return socket as unknown as import('node:net').Socket;
},
}),
);
process.env.SUBMINER_LOG_LEVEL = 'error';
try {
client.connect();
await waitFor(() => client.connected);
assert.equal(sockets.length, 2);
assert.equal(sockets[0]!.destroyed, true);
assert.equal(sockets[0]!.connectedPaths.at(0), '/tmp/mpv.sock');
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv.sock');
assert.equal(client.connected, true);
} finally {
if (originalLogLevel === undefined) {
delete process.env.SUBMINER_LOG_LEVEL;
} else {
process.env.SUBMINER_LOG_LEVEL = originalLogLevel;
}
if (reconnectTimer) clearTimeout(reconnectTimer);
(client as any).transport.shutdown();
}
});
test('MpvIpcClient.setSocketPath aborts an in-flight connect so the next dial targets the new path', () => {
const sockets: HangingTestSocket[] = [];
const client = new MpvIpcClient(
'/tmp/mpv-old.sock',
makeDeps({
socketFactory: () => {
const socket = new HangingTestSocket();
sockets.push(socket);
return socket as unknown as import('node:net').Socket;
},
}),
);
client.connect();
assert.equal(sockets.length, 1);
assert.equal(sockets[0]!.connectedPaths.at(0), '/tmp/mpv-old.sock');
assert.equal((client as any).connecting, true);
client.setSocketPath('/tmp/mpv-new.sock');
assert.equal((client as any).connecting, false);
assert.equal(sockets[0]!.destroyed, true);
client.connect();
assert.equal(sockets.length, 2);
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv-new.sock');
(client as any).transport.shutdown();
});
+17 -1
View File
@@ -9,7 +9,11 @@ import {
splitMpvMessagesFromBuffer,
} from './mpv-protocol';
import { requestMpvInitialState, subscribeToMpvProperties } from './mpv-properties';
import { scheduleMpvReconnect, MpvSocketTransport } from './mpv-transport';
import {
scheduleMpvReconnect,
MpvSocketTransport,
MpvSocketTransportOptions,
} from './mpv-transport';
import { createLogger } from '../../logger';
const logger = createLogger('main:mpv');
@@ -110,6 +114,8 @@ export interface MpvIpcClientProtocolDeps {
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
socketFactory?: MpvSocketTransportOptions['socketFactory'];
connectTimeoutMs?: number;
}
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
@@ -188,6 +194,8 @@ export class MpvIpcClient implements MpvClient {
this.transport = new MpvSocketTransport({
socketPath,
socketFactory: deps.socketFactory,
connectTimeoutMs: deps.connectTimeoutMs,
onConnect: () => {
this.connected = true;
this.connecting = false;
@@ -289,6 +297,14 @@ export class MpvIpcClient implements MpvClient {
previousSocketPath: this.socketPath,
socketPath,
});
if (this.connecting && !this.connected) {
// Abort the in-flight dial to the old path; otherwise the connecting
// latch turns every later connect() into a no-op while we hang on a
// stale socket.
logger.debug('Aborting in-flight MPV IPC connect for socket path change.');
this.transport.shutdown();
this.connecting = false;
}
}
this.socketPath = socketPath;
this.transport.setSocketPath(socketPath);
@@ -0,0 +1,21 @@
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 },
]);
});
@@ -0,0 +1,27 @@
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);
assert.ok(calls.includes('opacity:0'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('mouse-ignore:true:plain'));
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
@@ -1060,7 +1060,7 @@ test('tracked Windows overlay refresh rebinds while already visible', () => {
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('mouse-ignore:true:plain'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('show'));
@@ -1134,7 +1134,7 @@ test('forced passthrough still reapplies while visible on Windows', () => {
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('mouse-ignore:true:plain'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
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('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('mouse-ignore:true:plain'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
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,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('mouse-ignore:true:plain'));
assert.ok(calls.includes('show-inactive'));
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('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('mouse-ignore:true:plain'));
assert.ok(calls.includes('sync-windows-z-order'));
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('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('mouse-ignore:true:plain'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
+5 -4
View File
@@ -1,6 +1,7 @@
import type { BrowserWindow } from 'electron';
import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types';
import { applyOverlayClickThrough } from './overlay-click-through';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
@@ -117,7 +118,7 @@ export function updateVisibleOverlayVisibility(args: {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.setIgnoreMouseEvents(true, { forward: true });
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
releaseOverlayWindowLevel(mainWindow);
mainWindow.hide();
args.syncOverlayShortcuts();
@@ -215,7 +216,7 @@ export function updateVisibleOverlayVisibility(args: {
shouldPreserveWindowsOverlayDuringFocusHandoff ||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
if (shouldIgnoreMouseEvents) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
} else {
mainWindow.setIgnoreMouseEvents(false);
}
@@ -263,7 +264,7 @@ export function updateVisibleOverlayVisibility(args: {
if (hasNonNativeInputRegion) {
mainWindow.setIgnoreMouseEvents(false);
} else {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
}
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(
@@ -424,7 +425,7 @@ export function updateVisibleOverlayVisibility(args: {
return;
}
args.setTrackerNotReadyWarningShown(false);
mainWindow.setIgnoreMouseEvents(true, { forward: true });
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
releaseOverlayWindowLevel(mainWindow);
mainWindow.hide();
args.syncOverlayShortcuts();
@@ -15,6 +15,32 @@ test('overlay window config explicitly disables renderer sandbox for preload com
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', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
@@ -1 +1,2 @@
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
export const OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG = '__subminerOverlayDocumentLoaded';
+9 -4
View File
@@ -12,15 +12,17 @@ export function buildOverlayWindowOptions(
options: {
isDev: boolean;
linuxX11FullscreenOverlay?: boolean;
platform?: NodeJS.Platform;
yomitanSession?: Session | null;
},
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible';
const platform = options.platform ?? process.platform;
const showNativeDebugFrame = platform === 'win32' && options.isDev;
const isLinuxVisibleOverlay = platform === 'linux' && kind === 'visible';
const isLinuxFullscreenOverlay =
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
const shouldStartAlwaysOnTop =
!(process.platform === 'win32' && kind === 'visible') &&
!(platform === 'win32' && kind === 'visible') &&
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
@@ -41,7 +43,10 @@ export function buildOverlayWindowOptions(
hasShadow: false,
focusable: !isLinuxFullscreenOverlay,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
// A macOS panel is a fullscreen auxiliary window, so modal surfaces stay on the
// 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: {
preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true,
+16 -1
View File
@@ -16,7 +16,10 @@ import {
} from './hyprland-window-placement';
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
import {
OVERLAY_WINDOW_CONTENT_READY_FLAG,
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
} from './overlay-window-flags';
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
const logger = createLogger('main:overlay-window');
@@ -133,6 +136,9 @@ export function createOverlayWindow(
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false;
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
] = false;
if (!(process.platform === 'win32' && kind === 'visible')) {
options.ensureOverlayWindowLevel(window);
@@ -144,11 +150,20 @@ export function createOverlayWindow(
});
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]);
options.onRuntimeOptionsChanged();
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) => {
event.preventDefault();
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
@@ -10,7 +10,6 @@ import {
parseExcludedWordsBody,
parseIntQuery,
parsePositiveIdList,
loadKnownWordsSet,
} from './route-support.js';
export function registerStatsLibraryRoutes(
@@ -32,17 +31,6 @@ export function registerStatsLibraryRoutes(
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) => {
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
});
+11
View File
@@ -57,7 +57,9 @@ export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey:
export function buildStatsWindowOptions(options: {
preloadPath: string;
bounds?: WindowGeometry | null;
platform?: NodeJS.Platform;
}): BrowserWindowConstructorOptions {
const platform = options.platform ?? process.platform;
return {
title: STATS_WINDOW_TITLE,
x: options.bounds?.x,
@@ -73,6 +75,9 @@ export function buildStatsWindowOptions(options: {
focusable: true,
acceptFirstMouse: true,
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',
show: false,
webPreferences: {
@@ -84,6 +89,12 @@ export function buildStatsWindowOptions(options: {
};
}
export function shouldPresentStatsWindowAfterLoad(
platform: NodeJS.Platform = process.platform,
): boolean {
return platform === 'darwin';
}
export function resolveStatsWindowOuterBoundsForContent(
window: StatsWindowBoundsController,
target: WindowGeometry,
+25
View File
@@ -12,6 +12,7 @@ import {
scheduleStatsWindowPostShowReconciles,
showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput,
shouldPresentStatsWindowAfterLoad,
} from './stats-window-runtime';
test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => {
@@ -40,6 +41,30 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w
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', () => {
assert.equal(
shouldHideStatsWindowForInput(
+8 -2
View File
@@ -13,6 +13,7 @@ import {
scheduleStatsWindowPostShowReconciles,
showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput,
shouldPresentStatsWindowAfterLoad,
STATS_WINDOW_TITLE,
} from './stats-window-runtime.js';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
@@ -209,10 +210,15 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
options.onVisibilityChanged?.(false);
}
});
statsWindow.once('ready-to-show', () => {
const showInitialStatsWindow = () => {
if (!statsWindow) return;
showStatsWindow(statsWindow, options);
});
};
if (shouldPresentStatsWindowAfterLoad()) {
statsWindow.webContents.once('did-finish-load', showInitialStatsWindow);
} else {
statsWindow.once('ready-to-show', showInitialStatsWindow);
}
statsWindow.on('blur', () => {
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
@@ -15,7 +15,7 @@
* layer that keeps the two views consistent by construction.
* 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
* `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
* `subtitle-cue-dedup`, and it uses that path's deliberately strict bounds.
*/
+17 -1
View File
@@ -1,6 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createNotifySendReplacer, resolveDefaultNotificationIconPath } from './notification';
import {
buildNotifySendEnv,
createNotifySendReplacer,
resolveDefaultNotificationIconPath,
} from './notification';
test('notify-send child environment drops the AppImage library-path override', () => {
const env = buildNotifySendEnv({
LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
HOME: '/home/user',
});
assert.equal(env.LD_LIBRARY_PATH, undefined);
assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
assert.equal(env.HOME, '/home/user');
});
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
const path = resolveDefaultNotificationIconPath({
+12 -1
View File
@@ -203,8 +203,19 @@ export function createNotifySendReplacer(
};
}
/**
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/usr/lib`, whose bundled libnotify predates the
* symbols the system notify-send links against, so an inherited environment kills the child with a
* symbol lookup error before it can send anything. A system binary resolves its own libraries fine,
* so the override is dropped entirely rather than filtered.
*/
export function buildNotifySendEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
return rest;
}
const showLinuxReplaceableNotification = createNotifySendReplacer((args, callback) =>
execFile('notify-send', args, { timeout: 5_000 }, (error, stdout) =>
execFile('notify-send', args, { timeout: 5_000, env: buildNotifySendEnv() }, (error, stdout) =>
callback(error, stdout ?? ''),
),
);
+7
View File
@@ -24,10 +24,17 @@ import {
shouldForwardStartupArgvViaAppControl,
applyBackgroundBootstrapCommandLineSwitches,
applyEarlyLinuxCommandLineSwitches,
resolveAppControlHandoffTimeoutMs,
resolveLinuxPasswordStoreValue,
spawnDetachedApp,
} 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', () => {
const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
const runtimeSource = fs.readFileSync(
+10
View File
@@ -14,6 +14,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
const MAX_TRANSPORTED_APP_ARGS = 256;
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([
'--alang',
'--audio-file',
@@ -186,6 +188,14 @@ export function shouldForwardStartupArgvViaAppControl(
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 {
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
if (rawCount === undefined) {
+2 -1
View File
@@ -9,6 +9,7 @@ import {
normalizeLaunchMpvTargets,
normalizeStartupArgv,
applyEarlyLinuxCommandLineSwitches,
resolveAppControlHandoffTimeoutMs,
sanitizeStartupEnv,
sanitizeBackgroundEnv,
sanitizeHelpEnv,
@@ -214,7 +215,7 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
const result = await sendAppControlCommand(process.argv, {
configDir: userDataPath,
timeoutMs: 500,
timeoutMs: resolveAppControlHandoffTimeoutMs(),
});
if (result.ok) {
app.exit(0);
+5 -1
View File
@@ -331,6 +331,7 @@ import {
acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate';
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
@@ -5009,6 +5010,9 @@ function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
overlayModalRuntime.primeModalWindow();
}
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate,
@@ -5466,7 +5470,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
senderWindow === modalWindow &&
!senderWindow.isDestroyed()
) {
senderWindow.setIgnoreMouseEvents(true, { forward: true });
applyOverlayClickThrough(senderWindow);
senderWindow.hide();
}
handleOverlayModalClosedHandler(modal);
+19 -14
View File
@@ -244,13 +244,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
};
};
const findCachedSnapshotForSeriesKey = (
const findCachedSnapshotForSeriesKey = async (
seriesKey: string,
fallbackSeriesKey?: string,
): CharacterDictionarySnapshot | null => {
): Promise<CharacterDictionarySnapshot | null> => {
const acceptedKeys = new Set([seriesKey, fallbackSeriesKey].filter(Boolean));
return (
readCachedSnapshots(outputDir).find((snapshot) => {
(await readCachedSnapshots(outputDir)).find((snapshot) => {
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
mediaPath: null,
mediaTitle: snapshot.mediaTitle,
@@ -293,7 +293,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
const cachedResolution = readCachedMediaResolution(outputDir, seriesKey);
if (cachedResolution) {
const cachedSnapshot = readSnapshot(getSnapshotPath(outputDir, cachedResolution.mediaId));
const cachedSnapshot = await readSnapshot(
getSnapshotPath(outputDir, cachedResolution.mediaId),
);
if (cachedSnapshot) {
deps.logInfo?.(
`[dictionary] cached AniList match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
@@ -305,7 +307,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
}
}
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey, unscopedSeriesKey);
const cachedSnapshot = await findCachedSnapshotForSeriesKey(seriesKey, unscopedSeriesKey);
if (cachedSnapshot) {
writeCachedMediaResolution(outputDir, {
seriesKey,
@@ -348,7 +350,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
progress?: CharacterDictionarySnapshotProgressCallbacks,
): Promise<CharacterDictionarySnapshotResult> => {
const snapshotPath = getSnapshotPath(outputDir, mediaId);
const cachedSnapshot = readSnapshot(snapshotPath);
const cachedSnapshot = await readSnapshot(snapshotPath);
const refreshReason = cachedSnapshot ? getCachedSnapshotRefreshReason(cachedSnapshot) : null;
if (cachedSnapshot && refreshReason === null) {
deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`);
@@ -485,7 +487,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
resolvedNameSplits,
nameSplitSource,
);
writeSnapshot(snapshotPath, snapshot);
await writeSnapshot(snapshotPath, snapshot);
deps.logInfo?.(
`[dictionary] stored snapshot for AniList ${mediaId}: ${snapshot.entryCount} terms`,
);
@@ -526,19 +528,22 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
const snapshotResults = await Promise.all(
normalizedMediaIds.map((mediaId) => getOrCreateSnapshot(mediaId)),
);
const snapshots = snapshotResults.map(({ mediaId }) => {
const snapshot = readSnapshot(getSnapshotPath(outputDir, mediaId));
// Sequential on purpose: each snapshot parse is a chunk of main-thread work, so reading them
// one at a time keeps the event loop breathing between files.
const snapshots: CharacterDictionarySnapshot[] = [];
for (const { mediaId } of snapshotResults) {
const snapshot = await readSnapshot(getSnapshotPath(outputDir, mediaId));
if (!snapshot) {
throw new Error(`Missing character dictionary snapshot for AniList ${mediaId}.`);
}
return snapshot;
});
snapshots.push(snapshot);
}
const revision = buildMergedRevision(normalizedMediaIds, snapshots);
const description =
snapshots.length === 1
? `Character names from ${snapshots[0]!.mediaTitle}`
: `Character names from ${snapshots.length} recent anime`;
const { zipPath, entryCount } = buildDictionaryZip(
const { zipPath, entryCount } = await buildDictionaryZip(
getMergedZipPath(outputDir),
CHARACTER_DICTIONARY_MERGED_TITLE,
description,
@@ -633,7 +638,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
resolvedMedia.title,
waitForAniListRequestSlot,
);
const storedSnapshot = readSnapshot(getSnapshotPath(outputDir, resolvedMedia.id));
const storedSnapshot = await readSnapshot(getSnapshotPath(outputDir, resolvedMedia.id));
if (!storedSnapshot) {
throw new Error(`Snapshot missing after generation for AniList ${resolvedMedia.id}.`);
}
@@ -642,7 +647,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
const description = `Character names from ${storedSnapshot.mediaTitle} [AniList media ID ${resolvedMedia.id}]`;
const zipPath = path.join(outputDir, `anilist-${resolvedMedia.id}.zip`);
deps.logInfo?.(`[dictionary] building ZIP for AniList ${resolvedMedia.id}`);
buildDictionaryZip(
await buildDictionaryZip(
zipPath,
dictionaryTitle,
description,
@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import { isDeepStrictEqual } from 'node:util';
import { getSnapshotPath, readSnapshot, writeSnapshot } from './cache';
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import type { CharacterDictionarySnapshot } from './types';
@@ -29,17 +30,72 @@ function createSnapshot(): CharacterDictionarySnapshot {
};
}
test('writeSnapshot persists and readSnapshot restores current-format snapshots', () => {
test('writeSnapshot persists and readSnapshot restores current-format snapshots', async () => {
const outputDir = makeTempDir();
const snapshotPath = getSnapshotPath(outputDir, 130298);
const snapshot = createSnapshot();
writeSnapshot(snapshotPath, snapshot);
await writeSnapshot(snapshotPath, snapshot);
assert.deepEqual(readSnapshot(snapshotPath), { ...snapshot, nameSplitSource: 'heuristic' });
assert.deepEqual(await readSnapshot(snapshotPath), { ...snapshot, nameSplitSource: 'heuristic' });
});
test('readSnapshot preserves the mecab name-split source and defaults missing values to heuristic', () => {
// A manual generate and an auto-sync can both land on the same media, so two writes for one
// snapshot can overlap. They must not stream into a shared temp file and interleave into a
// half-and-half snapshot.
test('concurrent writeSnapshot calls for the same media leave one complete snapshot', async () => {
const outputDir = makeTempDir();
const snapshotPath = getSnapshotPath(outputDir, 130298);
const base = createSnapshot();
// Distinct titles, lengths, and term text so the surviving file can be pinned to exactly one
// writer rather than merely "a snapshot that parses". A shared temp file is caught by the
// losing writers failing to rename; interleaved content is only caught when the timing happens
// to leave a mix, which is why the assertion checks identity rather than shape.
const variants: CharacterDictionarySnapshot[] = ['alpha', 'beta', 'gamma'].map((label, index) => {
const entryCount = 400 + index * 100;
return {
...base,
mediaTitle: `${base.mediaTitle} ${label}`,
entryCount,
termEntries: Array.from({ length: entryCount }, (_entry, entryIndex) => [
`${label}${entryIndex}`,
'なまえ',
'name primary',
'',
75,
[`${label} character ${entryIndex} `.repeat(600)],
0,
'',
]) as CharacterDictionarySnapshot['termEntries'],
};
});
await Promise.all(variants.map((variant) => writeSnapshot(snapshotPath, variant)));
const restored = await readSnapshot(snapshotPath);
const expected = variants.map((variant) => ({
...variant,
nameSplitSource: 'heuristic' as const,
}));
const matches = expected.filter((candidate) => isDeepStrictEqual(restored, candidate));
assert.equal(
matches.length,
1,
`expected exactly one writer's complete snapshot to survive, got ${
restored === null
? 'an unreadable file'
: `entryCount=${restored.entryCount}, terms=${restored.termEntries.length}, title=${restored.mediaTitle}`
}`,
);
// Every writer cleaned up after itself, so no temp files are left behind.
const leftovers = fs
.readdirSync(path.dirname(snapshotPath))
.filter((name) => name.includes('.tmp-'));
assert.deepEqual(leftovers, []);
});
test('readSnapshot preserves the mecab name-split source and defaults missing values to heuristic', async () => {
const outputDir = makeTempDir();
const snapshotPath = getSnapshotPath(outputDir, 130298);
const snapshot: CharacterDictionarySnapshot = {
@@ -47,12 +103,12 @@ test('readSnapshot preserves the mecab name-split source and defaults missing va
nameSplitSource: 'mecab',
};
writeSnapshot(snapshotPath, snapshot);
await writeSnapshot(snapshotPath, snapshot);
assert.equal(readSnapshot(snapshotPath)?.nameSplitSource, 'mecab');
assert.equal((await readSnapshot(snapshotPath))?.nameSplitSource, 'mecab');
});
test('readSnapshot ignores snapshots written with an older format version', () => {
test('readSnapshot ignores snapshots written with an older format version', async () => {
const outputDir = makeTempDir();
const snapshotPath = getSnapshotPath(outputDir, 130298);
const staleSnapshot = {
@@ -63,10 +119,10 @@ test('readSnapshot ignores snapshots written with an older format version', () =
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
fs.writeFileSync(snapshotPath, JSON.stringify(staleSnapshot), 'utf8');
assert.equal(readSnapshot(snapshotPath), null);
assert.equal(await readSnapshot(snapshotPath), null);
});
test('readSnapshot ignores v15 snapshots with stale romanized character-name entries', () => {
test('readSnapshot ignores v15 snapshots with stale romanized character-name entries', async () => {
const outputDir = makeTempDir();
const snapshotPath = getSnapshotPath(outputDir, 130298);
const staleSnapshot = {
@@ -78,5 +134,5 @@ test('readSnapshot ignores v15 snapshots with stale romanized character-name ent
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
fs.writeFileSync(snapshotPath, JSON.stringify(staleSnapshot), 'utf8');
assert.equal(readSnapshot(snapshotPath), null);
assert.equal(await readSnapshot(snapshotPath), null);
});
+83 -10
View File
@@ -102,24 +102,42 @@ export function writeCachedMediaResolution(
writeMediaResolutionEntries(outputDir, [...remaining, normalized]);
}
export function readCachedSnapshots(outputDir: string): CharacterDictionarySnapshot[] {
/**
* Snapshots for long series run to hundreds of MB each, so everything here reads them off the main
* thread's critical path: file IO is async and only the unavoidable JSON.parse runs on the loop,
* one file at a time. Reading the whole directory synchronously used to block the process for
* multiple seconds, long enough for the compositor to declare the app unresponsive mid-playback.
*/
export async function readCachedSnapshots(
outputDir: string,
): Promise<CharacterDictionarySnapshot[]> {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
entries = await fs.promises.readdir(getSnapshotsDir(outputDir), { withFileTypes: true });
} catch {
return [];
}
return entries
const names = entries
.filter((entry) => entry.isFile() && /^anilist-\d+\.json$/.test(entry.name))
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => readSnapshot(path.join(getSnapshotsDir(outputDir), entry.name)))
.filter((snapshot): snapshot is CharacterDictionarySnapshot => snapshot !== null);
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
const snapshots: CharacterDictionarySnapshot[] = [];
for (const name of names) {
const snapshot = await readSnapshot(path.join(getSnapshotsDir(outputDir), name));
if (snapshot) {
snapshots.push(snapshot);
}
}
return snapshots;
}
export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
export async function readSnapshot(
snapshotPath: string,
): Promise<CharacterDictionarySnapshot | null> {
try {
const raw = fs.readFileSync(snapshotPath, 'utf8');
const raw = await fs.promises.readFile(snapshotPath, 'utf8');
const parsed = JSON.parse(raw) as Partial<CharacterDictionarySnapshot>;
if (!parsed || typeof parsed !== 'object') {
return null;
@@ -150,9 +168,64 @@ export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot
}
}
export function writeSnapshot(snapshotPath: string, snapshot: CharacterDictionarySnapshot): void {
// Flushing in a few-MB batches keeps each stringify-and-write slice short; a single
// JSON.stringify of a large snapshot blocks the event loop for seconds.
const SNAPSHOT_WRITE_FLUSH_BYTES = 4 * 1024 * 1024;
// Distinguishes concurrent writes of the same snapshot within one process; the pid alone only
// separates processes, so two overlapping writers would otherwise stream into the same temp file.
let snapshotWriteSequence = 0;
/**
* Streams the snapshot to disk piece by piece instead of stringifying it in one shot, then renames
* the finished file into place so a crash mid-write (or two concurrent writers for the same media)
* can never leave a torn file where a snapshot used to be.
*/
export async function writeSnapshot(
snapshotPath: string,
snapshot: CharacterDictionarySnapshot,
): Promise<void> {
ensureDir(path.dirname(snapshotPath));
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
snapshotWriteSequence += 1;
const tempPath = `${snapshotPath}.tmp-${process.pid}-${snapshotWriteSequence}`;
const handle = await fs.promises.open(tempPath, 'w');
try {
let buffered: string[] = [];
let bufferedBytes = 0;
const push = async (chunk: string): Promise<void> => {
buffered.push(chunk);
bufferedBytes += chunk.length;
if (bufferedBytes >= SNAPSHOT_WRITE_FLUSH_BYTES) {
const joined = buffered.join('');
buffered = [];
bufferedBytes = 0;
await handle.write(joined, null, 'utf8');
}
};
const writeArray = async (key: string, items: readonly unknown[]): Promise<void> => {
await push(`,${JSON.stringify(key)}:[`);
for (let i = 0; i < items.length; i += 1) {
await push(`${i > 0 ? ',' : ''}${JSON.stringify(items[i])}`);
}
await push(']');
};
const { termEntries, images, ...scalars } = snapshot;
const head = JSON.stringify(scalars);
await push(head.slice(0, -1));
await writeArray('termEntries', termEntries);
await writeArray('images', images);
await push('}');
if (buffered.length > 0) {
await handle.write(buffered.join(''), null, 'utf8');
}
} catch (error) {
await handle.close();
await fs.promises.rm(tempPath, { force: true });
throw error;
}
await handle.close();
await fs.promises.rename(tempPath, snapshotPath);
}
export function buildMergedRevision(
@@ -11,6 +11,22 @@ import {
} from './image-lookup';
import type { CharacterDictionarySnapshot } from './types';
// Lookup indexes rebuild in the background while gets serve stale data, so tests poll until the
// refresh they triggered has landed.
async function waitForRefresh<T>(probe: () => T | null | undefined): Promise<T> {
const deadline = Date.now() + 5000;
for (;;) {
const value = probe();
if (value !== null && value !== undefined) {
return value;
}
if (Date.now() > deadline) {
throw new Error('timed out waiting for background snapshot refresh');
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
const PNG_1X1_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=';
@@ -18,7 +34,7 @@ function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-image-lookup-'));
}
test('buildCharacterNameImageIndexFromSnapshots maps name terms to character portrait data URLs', () => {
test('buildCharacterNameImageIndexFromSnapshots maps name terms to character portrait data URLs', async () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
@@ -75,9 +91,9 @@ test('buildCharacterNameImageIndexFromSnapshots maps name terms to character por
{ path: 'img/m130298-va456.png', dataBase64: 'BBBB' },
],
};
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
const index = await buildCharacterNameImageIndexFromSnapshots(outputDir);
assert.deepEqual(index.get('アレクシア'), {
src: 'data:image/png;base64,AAAA',
@@ -85,7 +101,7 @@ test('buildCharacterNameImageIndexFromSnapshots maps name terms to character por
});
});
test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes before path extension', () => {
test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes before path extension', async () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
@@ -116,14 +132,14 @@ test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes bef
],
images: [{ path: 'img/m130298-c123.jpg', dataBase64: PNG_1X1_BASE64 }],
};
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
const index = await buildCharacterNameImageIndexFromSnapshots(outputDir);
assert.equal(index.get('アレクシア')?.src, `data:image/png;base64,${PNG_1X1_BASE64}`);
});
test('createCharacterDictionaryImageLookup can scope duplicate names to the current media', () => {
test('createCharacterDictionaryImageLookup can scope duplicate names to the current media', async () => {
const outputDir = makeTempDir();
const towerSnapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
@@ -173,15 +189,16 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr
],
images: [{ path: 'img/m21202-c2.png', dataBase64: 'KONOSUBA' }],
};
writeSnapshot(getSnapshotPath(outputDir, towerSnapshot.mediaId), towerSnapshot);
writeSnapshot(getSnapshotPath(outputDir, konosubaSnapshot.mediaId), konosubaSnapshot);
await writeSnapshot(getSnapshotPath(outputDir, towerSnapshot.mediaId), towerSnapshot);
await writeSnapshot(getSnapshotPath(outputDir, konosubaSnapshot.mediaId), konosubaSnapshot);
const lookup = createCharacterDictionaryImageLookup({ outputDir });
assert.equal(lookup.get('カズ', 21202)?.alt, 'Kazuma');
const scoped = await waitForRefresh(() => lookup.get('カズ', 21202));
assert.equal(scoped.alt, 'Kazuma');
});
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', () => {
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
@@ -208,10 +225,11 @@ test('createCharacterDictionaryImageLookup does not fall back globally on scoped
],
images: [{ path: 'img/m115230-c1.png', dataBase64: 'TOWER' }],
};
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const lookup = createCharacterDictionaryImageLookup({ outputDir });
const unscoped = await waitForRefresh(() => lookup.get('カズ'));
assert.equal(unscoped.alt, 'Kaz');
assert.equal(lookup.get('カズ', 21202), null);
assert.equal(lookup.get('カズ')?.alt, 'Kaz');
});
@@ -204,11 +204,11 @@ function getSnapshotDirectorySignature(outputDir: string): string {
return parts.sort().join('|');
}
export function buildCharacterNameImageIndexFromSnapshots(
export async function buildCharacterNameImageIndexFromSnapshots(
outputDir: string,
): Map<string, CharacterNameImage> {
): Promise<Map<string, CharacterNameImage>> {
const index = new Map<string, CharacterNameImage>();
for (const snapshot of readCachedSnapshots(outputDir)) {
for (const snapshot of await readCachedSnapshots(outputDir)) {
appendSnapshotImages(index, snapshot);
}
return index;
@@ -228,7 +228,12 @@ export function createCharacterDictionaryImageLookup(deps: {
let signature: string | null = null;
let index = new Map<string, CharacterNameImage>();
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
let refreshInFlight = false;
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
// now serve the previous index while a single background rebuild catches up; the swap is atomic
// and the signature only advances once the rebuild it belongs to has landed.
function refreshIfNeeded(): void {
if (!outputDir) {
index = new Map<string, CharacterNameImage>();
@@ -237,20 +242,30 @@ export function createCharacterDictionaryImageLookup(deps: {
return;
}
const nextSignature = getSnapshotDirectorySignature(outputDir);
if (nextSignature === signature) {
if (nextSignature === signature || refreshInFlight) {
return;
}
signature = nextSignature;
index = new Map<string, CharacterNameImage>();
indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
for (const snapshot of readCachedSnapshots(outputDir)) {
appendSnapshotImages(index, snapshot);
const mediaIndex = new Map<string, CharacterNameImage>();
appendSnapshotImages(mediaIndex, snapshot);
if (mediaIndex.size > 0) {
indexByMediaId.set(snapshot.mediaId, mediaIndex);
refreshInFlight = true;
void (async () => {
try {
const snapshots = await readCachedSnapshots(outputDir);
const nextIndex = new Map<string, CharacterNameImage>();
const nextIndexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
for (const snapshot of snapshots) {
appendSnapshotImages(nextIndex, snapshot);
const mediaIndex = new Map<string, CharacterNameImage>();
appendSnapshotImages(mediaIndex, snapshot);
if (mediaIndex.size > 0) {
nextIndexByMediaId.set(snapshot.mediaId, mediaIndex);
}
}
index = nextIndex;
indexByMediaId = nextIndexByMediaId;
signature = nextSignature;
} finally {
refreshInFlight = false;
}
}
})();
}
return {
@@ -32,17 +32,33 @@ function writeSnapshot(outputDir: string, mediaId: number, entries: Array<[strin
);
}
function withTempDir<T>(run: (dir: string) => T): T {
async function withTempDir<T>(run: (dir: string) => Promise<T> | T): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-name-candidates-'));
try {
return run(dir);
return await run(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('collects terms and readings for the current media', () => {
withTempDir((dir) => {
// The snapshot index rebuilds in the background while lookups serve stale data, so tests poll the
// probe until the refresh they triggered has landed.
async function waitForRefresh<T>(probe: () => T | null | undefined): Promise<T> {
const deadline = Date.now() + 5000;
for (;;) {
const value = probe();
if (value !== null && value !== undefined) {
return value;
}
if (Date.now() > deadline) {
throw new Error('timed out waiting for background snapshot refresh');
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
test('collects terms and readings for the current media', async () => {
await withTempDir(async (dir) => {
writeSnapshot(dir, 1, [
['ミナト', 'みなと'],
['湊', 'みなと'],
@@ -53,17 +69,16 @@ test('collects terms and readings for the current media', () => {
outputDir: dir,
getCurrentMediaId: () => 1,
});
const candidates = lookup.get();
const candidates = await waitForRefresh(() => lookup.get());
assert.ok(candidates);
assert.deepEqual([...candidates.forms].sort(), ['みなと', 'ミナト', '湊'].sort());
// Deduplicated: both entries share the みなと reading.
assert.equal(candidates.forms.length, 3);
});
});
test('returns null without a media scope so the scanner stays exhaustive', () => {
withTempDir((dir) => {
test('returns null without a media scope so the scanner stays exhaustive', async () => {
await withTempDir(async (dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
const lookup = createCharacterNameCandidateLookup({
@@ -71,12 +86,14 @@ test('returns null without a media scope so the scanner stays exhaustive', () =>
getCurrentMediaId: () => null,
});
// The explicitly-scoped probe proves the index has loaded before the unscoped case is judged.
await waitForRefresh(() => lookup.get(1));
assert.equal(lookup.get(), null);
});
});
test('returns null for a media with no cached snapshot', () => {
withTempDir((dir) => {
test('returns null for a media with no cached snapshot', async () => {
await withTempDir(async (dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
const lookup = createCharacterNameCandidateLookup({
@@ -84,29 +101,31 @@ test('returns null for a media with no cached snapshot', () => {
getCurrentMediaId: () => 999,
});
await waitForRefresh(() => lookup.get(1));
assert.equal(lookup.get(), null);
});
});
test('key changes when the snapshot content changes', () => {
withTempDir((dir) => {
test('key changes when the snapshot content changes', async () => {
await withTempDir(async (dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
const lookup = createCharacterNameCandidateLookup({
outputDir: dir,
getCurrentMediaId: () => 1,
});
const first = lookup.get();
const first = await waitForRefresh(() => lookup.get());
writeSnapshot(dir, 1, [
['ミナト', 'みなと'],
['アクア', 'あくあ'],
]);
lookup.invalidate();
const second = lookup.get();
const second = await waitForRefresh(() => {
const candidates = lookup.get();
return candidates && candidates.forms.length === 4 ? candidates : null;
});
assert.ok(first && second);
assert.notEqual(first.key, second.key);
assert.equal(second.forms.length, 4);
});
});
@@ -114,8 +133,8 @@ test('key changes when the snapshot content changes', () => {
// directory every call. Asserted behaviorally: an unannounced on-disk change is
// invisible until the recheck interval elapses, which can only be true if the
// filesystem is not consulted per lookup.
test('does not re-read the snapshot directory on every lookup', () => {
withTempDir((dir) => {
test('does not re-read the snapshot directory on every lookup', async () => {
await withTempDir(async (dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
let nowMs = 1_000_000;
const lookup = createCharacterNameCandidateLookup({
@@ -124,6 +143,7 @@ test('does not re-read the snapshot directory on every lookup', () => {
now: () => nowMs,
});
await waitForRefresh(() => lookup.get());
assert.equal(lookup.get()?.forms.length, 2);
writeSnapshot(dir, 1, [
@@ -135,12 +155,16 @@ test('does not re-read the snapshot directory on every lookup', () => {
assert.equal(lookup.get()?.forms.length, 2, 'expected the cached list within the interval');
nowMs += 10_000;
assert.equal(lookup.get()?.forms.length, 4, 'expected a refresh past the interval');
const refreshed = await waitForRefresh(() => {
const candidates = lookup.get();
return candidates && candidates.forms.length === 4 ? candidates : null;
});
assert.equal(refreshed.forms.length, 4, 'expected a refresh past the interval');
});
});
test('invalidate picks up a snapshot change immediately', () => {
withTempDir((dir) => {
test('invalidate picks up a snapshot change on the next refresh', async () => {
await withTempDir(async (dir) => {
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
let nowMs = 1_000_000;
const lookup = createCharacterNameCandidateLookup({
@@ -149,6 +173,7 @@ test('invalidate picks up a snapshot change immediately', () => {
now: () => nowMs,
});
await waitForRefresh(() => lookup.get());
assert.equal(lookup.get()?.forms.length, 2);
writeSnapshot(dir, 1, [
@@ -158,6 +183,10 @@ test('invalidate picks up a snapshot change immediately', () => {
nowMs += 1;
lookup.invalidate();
assert.equal(lookup.get()?.forms.length, 4);
const refreshed = await waitForRefresh(() => {
const candidates = lookup.get();
return candidates && candidates.forms.length === 4 ? candidates : null;
});
assert.equal(refreshed.forms.length, 4);
});
});
@@ -98,7 +98,12 @@ export function createCharacterNameCandidateLookup(deps: {
let signature: string | null = null;
let lastSignatureCheckAtMs = 0;
let formsByMediaId = new Map<number, string[]>();
let refreshInFlight = false;
// Same stale-while-revalidate shape as the image lookup: the rebuild re-reads every cached
// snapshot, so it runs in the background while lookups keep serving the previous forms. The
// signature only advances once its rebuild has landed, so a failed or superseded rebuild is
// retried on the next signature check.
function refreshIfNeeded(): void {
if (!outputDir) {
formsByMediaId = new Map<number, string[]>();
@@ -114,17 +119,26 @@ export function createCharacterNameCandidateLookup(deps: {
}
lastSignatureCheckAtMs = nowMs;
const nextSignature = getSnapshotDirectorySignature(outputDir);
if (nextSignature === signature) {
if (nextSignature === signature || refreshInFlight) {
return;
}
signature = nextSignature;
formsByMediaId = new Map<number, string[]>();
for (const snapshot of readCachedSnapshots(outputDir)) {
const forms = collectSnapshotNameForms(snapshot);
if (forms.length > 0) {
formsByMediaId.set(snapshot.mediaId, forms);
refreshInFlight = true;
void (async () => {
try {
const snapshots = await readCachedSnapshots(outputDir);
const nextFormsByMediaId = new Map<number, string[]>();
for (const snapshot of snapshots) {
const forms = collectSnapshotNameForms(snapshot);
if (forms.length > 0) {
nextFormsByMediaId.set(snapshot.mediaId, forms);
}
}
formsByMediaId = nextFormsByMediaId;
signature = nextSignature;
} finally {
refreshInFlight = false;
}
}
})();
}
return {
@@ -34,7 +34,7 @@ function createSnapshotWithoutImages(): CharacterDictionarySnapshot {
test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async () => {
const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries');
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
await writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
const originalFetch = globalThis.fetch;
const fetchUrls: string[] = [];
@@ -124,7 +124,7 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries');
writeSnapshot(getSnapshotPath(outputDir, 130298), {
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
...createSnapshotWithoutImages(),
nameSplitSource: 'heuristic',
});
@@ -213,7 +213,7 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries');
writeSnapshot(getSnapshotPath(outputDir, 130298), {
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
...createSnapshotWithoutImages(),
nameSplitSource: 'mecab',
});
@@ -253,7 +253,7 @@ test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is availabl
test('generateForCurrentMedia keeps heuristic-split snapshots while MeCab is unavailable', async () => {
const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries');
writeSnapshot(getSnapshotPath(outputDir, 130298), {
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
...createSnapshotWithoutImages(),
nameSplitSource: 'heuristic',
});
@@ -293,7 +293,7 @@ test('generateForCurrentMedia keeps heuristic-split snapshots while MeCab is una
test('generateForCurrentMedia keeps same-version snapshots without images when inline images are disabled', async () => {
const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries');
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
await writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
@@ -42,7 +42,7 @@ function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
return entries;
}
test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', () => {
test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', async () => {
const tempDir = makeTempDir();
const outputPath = path.join(tempDir, 'dictionary.zip');
const termEntries: CharacterDictionaryTermEntry[] = [
@@ -62,7 +62,7 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
);
}) as typeof Buffer.concat;
const result = buildDictionaryZip(
const result = await buildDictionaryZip(
outputPath,
'Dictionary Title',
'Dictionary Description',
@@ -106,11 +106,11 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
}
});
test('readDictionaryZipRevision reads the built revision and rejects foreign archives', () => {
test('readDictionaryZipRevision reads the built revision and rejects foreign archives', async () => {
const dir = makeTempDir();
try {
const zipPath = path.join(dir, 'merged.zip');
buildDictionaryZip(
await buildDictionaryZip(
zipPath,
'SubMiner Character Dictionary',
'Character names',
+9 -5
View File
@@ -1,5 +1,5 @@
import * as path from 'path';
import { readStoredZipFirstFile, writeStoredZip } from '../../shared/stored-zip';
import { readStoredZipFirstFile, writeStoredZipAsync } from '../../shared/stored-zip';
import { ensureDir } from './fs-utils';
import type { CharacterDictionarySnapshotImage, CharacterDictionaryTermEntry } from './types';
@@ -48,14 +48,14 @@ export function readDictionaryZipRevision(zipPath: string): string | null {
}
}
export function buildDictionaryZip(
export async function buildDictionaryZip(
outputPath: string,
dictionaryTitle: string,
description: string,
revision: string,
termEntries: CharacterDictionaryTermEntry[],
images: CharacterDictionarySnapshotImage[],
): { zipPath: string; entryCount: number } {
): Promise<{ zipPath: string; entryCount: number }> {
ensureDir(path.dirname(outputPath));
function* zipFiles(): Iterable<{ name: string; data: Buffer }> {
@@ -78,7 +78,11 @@ export function buildDictionaryZip(
};
}
const entriesPerBank = 10_000;
// Each bank is stringified in one shot, so the bank size sets the longest single block in the
// build. 10k entries measured ~38MB and ~135ms per bank on a real merged dictionary; 2k keeps
// every bank under the archive writer's yield budget at ~27ms. Yomitan reads any number of
// term_bank_N.json files, so this only changes how the terms are split across them.
const entriesPerBank = 2_000;
for (let i = 0; i < termEntries.length; i += entriesPerBank) {
yield {
name: `term_bank_${Math.floor(i / entriesPerBank) + 1}.json`,
@@ -87,6 +91,6 @@ export function buildDictionaryZip(
}
}
writeStoredZip(outputPath, zipFiles());
await writeStoredZipAsync(outputPath, zipFiles());
return { zipPath: outputPath, entryCount: termEntries.length };
}
+321 -49
View File
@@ -16,6 +16,7 @@ type MockWindow = {
loading: boolean;
url: string;
contentReady: boolean;
documentLoaded: boolean;
loadCallbacks: Array<() => void>;
readyToShowCallbacks: Array<() => void>;
};
@@ -31,6 +32,7 @@ function createMockWindow(): MockWindow & {
getShowCount: () => number;
getHideCount: () => number;
show: () => void;
showInactive: () => void;
hide: () => void;
destroy: () => void;
focus: () => void;
@@ -61,6 +63,7 @@ function createMockWindow(): MockWindow & {
loading: false,
url: 'file:///overlay/index.html?layer=modal',
contentReady: true,
documentLoaded: true,
loadCallbacks: [],
readyToShowCallbacks: [],
};
@@ -84,6 +87,10 @@ function createMockWindow(): MockWindow & {
state.visible = true;
state.showCount += 1;
},
showInactive: () => {
state.visible = true;
state.showCount += 1;
},
hide: () => {
state.visible = false;
state.hideCount += 1;
@@ -96,6 +103,10 @@ function createMockWindow(): MockWindow & {
state.focused = true;
},
emitDidFinishLoad: () => {
state.documentLoaded = true;
(
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
).__subminerOverlayDocumentLoaded = true;
const callbacks = state.loadCallbacks.splice(0);
for (const callback of callbacks) {
callback();
@@ -197,9 +208,22 @@ 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 }
).__subminerOverlayContentReady = state.contentReady;
(
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
).__subminerOverlayDocumentLoaded = state.documentLoaded;
return window;
}
@@ -259,6 +283,73 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
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', () => {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
@@ -301,7 +392,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal';
window.emitDidFinishLoad();
assert.deepEqual(window.sent, []);
assert.deepEqual(window.sent, [['runtime-options:open']]);
window.contentReady = true;
window.emitReadyToShow();
@@ -311,15 +402,18 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
assert.equal(window.getShowCount(), 1);
});
test('handleOverlayModalClosed hides modal window only after all pending modals close', () => {
test('handleOverlayModalClosed keeps the modal window warm after all pending modals close', () => {
const window = createMockWindow();
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{ platform: 'darwin' },
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
@@ -342,7 +436,9 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
assert.equal(window.isDestroyed(), false);
runtime.handleOverlayModalClosed('subsync');
assert.equal(window.isDestroyed(), true);
assert.equal(window.isDestroyed(), false);
assert.equal(window.isVisible(), false);
assert.equal(window.ignoreMouseEvents, true);
});
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
@@ -464,6 +560,46 @@ test('modal window path restores visible main overlay before modal input deactiv
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', () => {
const mainWindow = createMockWindow();
mainWindow.visible = true;
@@ -650,15 +786,18 @@ test('handleOverlayModalClosed is a no-op when no modal window can be targeted',
assert.deepEqual(state, []);
});
test('handleOverlayModalClosed destroys modal window for single kiku modal', () => {
test('handleOverlayModalClosed hides and retains modal window for single kiku modal', () => {
const window = createMockWindow();
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{ platform: 'darwin' },
);
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
@@ -669,7 +808,9 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', ()
);
runtime.handleOverlayModalClosed('kiku');
assert.equal(window.isDestroyed(), true);
assert.equal(window.isDestroyed(), false);
assert.equal(window.isVisible(), false);
assert.equal(window.ignoreMouseEvents, true);
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
});
@@ -719,8 +860,10 @@ test('modal fallback reveal skips showing window when content is not ready', asy
assert.equal(window.ignoreMouseEvents, false);
});
test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => {
test('sendToActiveOverlayWindow delivers on first modal load without waiting for ready-to-show', () => {
const window = createMockWindow();
window.loading = true;
window.url = '';
window.contentReady = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
@@ -738,16 +881,100 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering
assert.equal(sent, true);
assert.deepEqual(window.sent, []);
window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal';
window.emitDidFinishLoad();
assert.deepEqual(window.sent, []);
assert.deepEqual(window.sent, [['runtime-options:open']]);
window.contentReady = true;
window.emitReadyToShow();
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', () => {
const window = createMockWindow();
window.loading = true;
window.url = '';
window.contentReady = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
@@ -773,29 +1000,73 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef
);
assert.deepEqual(window.sent, []);
window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal';
window.emitDidFinishLoad();
assert.deepEqual(window.sent, []);
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
window.contentReady = true;
window.emitReadyToShow();
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
});
test('modal reopen creates a fresh window after close destroys the previous one', () => {
const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
test('modal reopen reuses the warm window and shows it immediately on macOS', () => {
const modalWindow = createMockWindow();
let createCalls = 0;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => currentModal as never,
createModalWindow: () => {
currentModal = secondWindow;
return secondWindow as never;
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: () => {},
},
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 replacementWindow = createMockWindow();
let currentModal = firstWindow;
let createCalls = 0;
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => currentModal as never,
createModalWindow: () => {
createCalls += 1;
currentModal = replacementWindow;
return replacementWindow as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{ platform: 'win32' },
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
@@ -804,30 +1075,30 @@ test('modal reopen creates a fresh window after close destroys the previous one'
runtime.handleOverlayModalClosed('runtime-options');
assert.equal(firstWindow.isDestroyed(), true);
assert.equal(currentModal, replacementWindow);
assert.equal(replacementWindow.isVisible(), false);
assert.equal(createCalls, 1);
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
const sent = runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
restoreOnModalClose: 'session-help',
});
assert.equal(sent, true);
assert.equal(currentModal, secondWindow);
assert.equal(secondWindow.getShowCount(), 0);
assert.equal(createCalls, 1);
assert.equal(replacementWindow.isVisible(), true);
assert.equal(replacementWindow.ignoreMouseEvents, false);
assert.deepEqual(replacementWindow.sent, [['session-help:open']]);
});
test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => {
const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
test('modal reopen on the warm window notifies state change for each lifecycle', () => {
const modalWindow = createMockWindow();
const state: boolean[] = [];
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => currentModal as never,
createModalWindow: () => {
currentModal = secondWindow;
return secondWindow as never;
},
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
@@ -835,6 +1106,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
onModalStateChange: (active: boolean): void => {
state.push(active);
},
platform: 'darwin',
},
);
@@ -845,7 +1117,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
runtime.handleOverlayModalClosed('runtime-options');
assert.deepEqual(state, [true, false]);
assert.equal(firstWindow.isDestroyed(), true);
assert.equal(modalWindow.isDestroyed(), false);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
@@ -853,7 +1125,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(state, [true, false, true]);
assert.equal(currentModal, secondWindow);
assert.equal(modalWindow.isVisible(), true);
});
test('visible stale modal window is made interactive again before reopening', () => {
+101 -19
View File
@@ -2,7 +2,11 @@ import type { BrowserWindow } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types';
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
import { applyOverlayClickThrough } from '../core/services/overlay-click-through';
import {
OVERLAY_WINDOW_CONTENT_READY_FLAG,
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
} from '../core/services/overlay-window-flags';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
@@ -39,6 +43,7 @@ export interface OverlayWindowResolver {
}
export interface OverlayModalRuntime {
primeModalWindow: () => boolean;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
@@ -59,6 +64,8 @@ export interface OverlayModalRuntime {
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
export interface OverlayModalRuntimeOptions {
platform?: NodeJS.Platform;
focusApplication?: () => void;
onModalStateChange?: (isActive: boolean) => void;
onFinalModalClosed?: () => void;
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
@@ -79,6 +86,11 @@ export function createOverlayModalRuntimeService(
let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
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 =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
@@ -134,7 +146,11 @@ export function createOverlayModalRuntimeService(
}
const overlayWindow = window as BrowserWindow & {
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
};
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === false) {
return false;
}
if (
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
@@ -145,6 +161,50 @@ export function createOverlayModalRuntimeService(
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 => {
if (window.isDestroyed()) return;
window.setAlwaysOnTop(true, 'screen-saver', 3);
@@ -205,16 +265,19 @@ export function createOverlayModalRuntimeService(
}
let delivered = false;
const deliverWhenReady = (): void => {
if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
const deliver = (isReady: () => boolean): void => {
if (delivered || window.isDestroyed() || !isReady()) {
return;
}
delivered = true;
sendNow(window);
};
window.webContents.once('did-finish-load', deliverWhenReady);
window.once('ready-to-show', deliverWhenReady);
// A hidden macOS panel may not emit ready-to-show until it is presented. The
// renderer can safely receive IPC as soon as its document has finished loading.
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
deliver(() => isWindowLoadedForIpc(window));
};
const showModalWindow = (
@@ -224,13 +287,20 @@ export function createOverlayModalRuntimeService(
} = { passThroughMouseEvents: false },
): void => {
setWindowFocusable(window);
requestOverlayApplicationFocus();
if (!window.isVisible()) {
const wasVisible = window.isVisible();
if (!wasVisible && platform === 'darwin') {
// Mapping the panel first keeps it attached to mpv's active fullscreen Space.
window.showInactive();
focusApplication();
} else {
focusApplication();
}
if (!wasVisible && platform !== 'darwin') {
window.show();
}
elevateModalWindow(window);
if (options.passThroughMouseEvents) {
window.setIgnoreMouseEvents(true, { forward: true });
applyOverlayClickThrough(window, platform === 'win32');
} else {
window.setIgnoreMouseEvents(false);
}
@@ -245,11 +315,11 @@ export function createOverlayModalRuntimeService(
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
setWindowFocusable(window);
requestOverlayApplicationFocus();
window.setIgnoreMouseEvents(false);
elevateModalWindow(window);
if (window.isVisible()) {
focusApplication();
window.focus();
window.webContents.focus();
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
@@ -291,7 +361,7 @@ export function createOverlayModalRuntimeService(
mainWindowMousePassthroughForcedByModal = false;
return;
}
mainWindow.setIgnoreMouseEvents(true, { forward: true });
applyOverlayClickThrough(mainWindow, platform === 'win32');
mainWindowMousePassthroughForcedByModal = true;
return;
}
@@ -447,9 +517,21 @@ export function createOverlayModalRuntimeService(
if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal();
if (modalWindow && !modalWindow.isDestroyed()) {
modalWindow.destroy();
if (reuseModalWindowAfterClose) {
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;
setMainWindowVisibilityForModal(false);
try {
@@ -478,17 +560,16 @@ export function createOverlayModalRuntimeService(
}
const modalWindow = deps.getModalWindow();
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
} else {
showModalWindow(targetWindow);
}
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
}
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
return;
}
showModalWindow(targetWindow);
};
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
@@ -515,6 +596,7 @@ export function createOverlayModalRuntimeService(
});
return {
primeModalWindow,
sendToActiveOverlayWindow,
openRuntimeOptionsPalette,
openJimaku,
+3 -1
View File
@@ -1,3 +1,5 @@
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
type StatsOverlayVisibilityWindow = {
isDestroyed: () => boolean;
isVisible: () => boolean;
@@ -8,7 +10,7 @@ function makeOverlayMousePassive(window: StatsOverlayVisibilityWindow | null): v
if (!window || window.isDestroyed() || !window.isVisible()) {
return;
}
window.setIgnoreMouseEvents(true, { forward: true });
applyOverlayClickThrough(window);
}
export function createStatsOverlayVisibilityChangeHandler(deps: {
+17 -1
View File
@@ -31,6 +31,20 @@ function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; command
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 () => {
const { spawn, children, commands } = makeSpawn();
const events: SyncProgressEvent[] = [];
@@ -96,7 +110,9 @@ test('runSyncLauncher settles after exit when close never arrives', async () =>
// so `close` never fires.
child.emit('exit', 1, null);
const result = await handle.done;
// Keep the isolated Bun test process alive while the production drain timer
// 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.match(result.error ?? '', /remote refused/);
});
+1 -1
View File
@@ -66,7 +66,7 @@ export function runSyncLauncher(options: {
spawn?: SyncLauncherSpawn;
timeoutMs?: number;
}): SyncLauncherRunHandle {
const spawn =
const spawn: SyncLauncherSpawn =
options.spawn ??
((command, args) => {
// The child must boot as a full Electron app (its entry handles
@@ -1,6 +1,7 @@
import { type BrowserWindow, screen } from 'electron';
import { execFile } from 'node:child_process';
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
@@ -603,7 +604,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
if (active) {
mainWindow.setIgnoreMouseEvents(false);
} else {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
applyOverlayClickThrough(mainWindow);
}
}
+74
View File
@@ -0,0 +1,74 @@
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 { readStoredZipFirstFile, writeStoredZip, writeStoredZipAsync } from './stored-zip';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stored-zip-'));
}
function readEntries(zipPath: string): Map<string, Buffer> {
const archive = fs.readFileSync(zipPath);
const entries = new Map<string, Buffer>();
let cursor = 0;
while (cursor + 4 <= archive.length) {
const signature = archive.readUInt32LE(cursor);
if (signature === 0x02014b50 || signature === 0x06054b50) {
break;
}
assert.equal(signature, 0x04034b50, `unexpected local file header at offset ${cursor}`);
const size = archive.readUInt32LE(cursor + 18);
const nameLength = archive.readUInt16LE(cursor + 26);
const extraLength = archive.readUInt16LE(cursor + 28);
const nameStart = cursor + 30;
const dataStart = nameStart + nameLength + extraLength;
entries.set(
archive.subarray(nameStart, nameStart + nameLength).toString('utf8'),
Buffer.from(archive.subarray(dataStart, dataStart + size)),
);
cursor = dataStart + size;
}
return entries;
}
// The async writer yields on a byte budget between entries, so an entry that is itself larger than
// that budget is the case where the accounting could drift: the offsets, CRCs, and central
// directory all have to come out identical to the synchronous writer.
test('writeStoredZipAsync writes a correct archive when one entry exceeds the yield budget', async () => {
const dir = makeTempDir();
try {
// Comfortably past the writer's 8MB yield budget.
const oversized = Buffer.alloc(10 * 1024 * 1024);
for (let i = 0; i < oversized.length; i += 1) {
oversized[i] = i % 251;
}
const files = [
{ name: 'index.json', data: Buffer.from('{"revision":"rev-1"}', 'utf8') },
{ name: 'big.bin', data: oversized },
{ name: 'after.txt', data: Buffer.from('written after the oversized entry', 'utf8') },
];
const asyncPath = path.join(dir, 'async.zip');
const syncPath = path.join(dir, 'sync.zip');
const asyncResult = await writeStoredZipAsync(asyncPath, files);
const syncResult = writeStoredZip(syncPath, files);
assert.equal(asyncResult.entryCount, 3);
assert.deepEqual(asyncResult, syncResult);
// Byte-identical to the synchronous writer: yielding mid-archive changed no offset or CRC.
assert.ok(fs.readFileSync(asyncPath).equals(fs.readFileSync(syncPath)));
const entries = readEntries(asyncPath);
assert.deepEqual([...entries.keys()], ['index.json', 'big.bin', 'after.txt']);
assert.ok(entries.get('big.bin')!.equals(oversized));
assert.equal(entries.get('after.txt')!.toString('utf8'), 'written after the oversized entry');
// The trailing records still parse, which is what proves the archive is complete.
assert.equal(readStoredZipFirstFile(asyncPath)?.name, 'index.json');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+103 -47
View File
@@ -1,4 +1,5 @@
import * as fs from 'fs';
import * as zlib from 'zlib';
type ZipEntry = {
name: string;
@@ -36,10 +37,17 @@ const CRC32_TABLE = (() => {
return table;
})();
// Native CRC32 (Node >= 20.15) runs at native throughput, which matters for the multi-hundred-MB
// dictionary archives; the table loop stays as a fallback for runtimes without it.
const nativeCrc32 = (zlib as { crc32?: (data: Uint8Array, value?: number) => number }).crc32;
function crc32(data: Buffer): number {
if (typeof nativeCrc32 === 'function') {
return nativeCrc32(data) >>> 0;
}
let crc = 0xffffffff;
for (const byte of data) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
for (let i = 0; i < data.length; i += 1) {
crc = CRC32_TABLE[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
@@ -294,59 +302,70 @@ function writeBuffer(fd: number, buffer: Buffer): void {
}
}
type ZipWriteState = {
entries: ZipEntry[];
offset: number;
};
/** Appends one stored entry (local header + data) and returns the bytes written. */
function appendStoredZipFile(fd: number, state: ZipWriteState, file: StoredZipFile): number {
const fileName = Buffer.from(file.name, 'utf8');
const fileSize = file.data.length;
if (fileName.length > ZIP32_MAX_UINT16) {
throw new RangeError(`ZIP entry name too long: ${file.name}`);
}
if (fileSize > ZIP32_MAX_UINT32) {
throw new RangeError(`ZIP entry too large for ZIP32: ${file.name}`);
}
if (state.offset > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
const fileCrc32 = crc32(file.data);
const localHeader = createLocalFileHeader(fileName, fileCrc32, fileSize);
const nextOffset = state.offset + localHeader.length + fileSize;
if (nextOffset > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
writeBuffer(fd, localHeader);
writeBuffer(fd, file.data);
state.entries.push({
name: file.name,
crc32: fileCrc32,
size: fileSize,
localHeaderOffset: state.offset,
});
const written = nextOffset - state.offset;
state.offset = nextOffset;
return written;
}
function finishStoredZip(fd: number, state: ZipWriteState): void {
const centralStart = state.offset;
if (centralStart > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
for (const entry of state.entries) {
const centralHeader = createCentralDirectoryHeader(entry);
writeBuffer(fd, centralHeader);
state.offset += centralHeader.length;
}
const centralSize = state.offset - centralStart;
writeBuffer(fd, createEndOfCentralDirectory(state.entries.length, centralSize, centralStart));
}
export function writeStoredZip(
outputPath: string,
files: Iterable<StoredZipFile>,
): { entryCount: number } {
const entries: ZipEntry[] = [];
let offset = 0;
const state: ZipWriteState = { entries: [], offset: 0 };
const fd = fs.openSync(outputPath, 'w');
try {
for (const file of files) {
const fileName = Buffer.from(file.name, 'utf8');
const fileSize = file.data.length;
if (fileName.length > ZIP32_MAX_UINT16) {
throw new RangeError(`ZIP entry name too long: ${file.name}`);
}
if (fileSize > ZIP32_MAX_UINT32) {
throw new RangeError(`ZIP entry too large for ZIP32: ${file.name}`);
}
if (offset > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
const fileCrc32 = crc32(file.data);
const localHeader = createLocalFileHeader(fileName, fileCrc32, fileSize);
const nextOffset = offset + localHeader.length + fileSize;
if (nextOffset > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
writeBuffer(fd, localHeader);
writeBuffer(fd, file.data);
entries.push({
name: file.name,
crc32: fileCrc32,
size: fileSize,
localHeaderOffset: offset,
});
if (nextOffset > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
offset = nextOffset;
appendStoredZipFile(fd, state, file);
}
const centralStart = offset;
if (centralStart > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
for (const entry of entries) {
const centralHeader = createCentralDirectoryHeader(entry);
writeBuffer(fd, centralHeader);
offset += centralHeader.length;
}
const centralSize = offset - centralStart;
writeBuffer(fd, createEndOfCentralDirectory(entries.length, centralSize, centralStart));
finishStoredZip(fd, state);
} catch (error) {
fs.closeSync(fd);
fs.rmSync(outputPath, { force: true });
@@ -354,5 +373,42 @@ export function writeStoredZip(
}
fs.closeSync(fd);
return { entryCount: entries.length };
return { entryCount: state.entries.length };
}
// Yielding roughly every 8MB keeps individual event-loop blocks in the low tens of milliseconds
// while adding a negligible number of macrotask hops even for the largest merged dictionary.
const ASYNC_ZIP_YIELD_BYTE_BUDGET = 8 * 1024 * 1024;
/**
* Same archive as {@link writeStoredZip}, written without starving the event loop: entry
* generation, CRC, and writes proceed in byte-budgeted slices with a macrotask yield in between.
* Multi-hundred-MB dictionary archives previously blocked the main process long enough for the
* compositor to declare the app unresponsive.
*/
export async function writeStoredZipAsync(
outputPath: string,
files: Iterable<StoredZipFile>,
): Promise<{ entryCount: number }> {
const state: ZipWriteState = { entries: [], offset: 0 };
const fd = fs.openSync(outputPath, 'w');
try {
let bytesSinceYield = 0;
for (const file of files) {
bytesSinceYield += appendStoredZipFile(fd, state, file);
if (bytesSinceYield >= ASYNC_ZIP_YIELD_BYTE_BUDGET) {
bytesSinceYield = 0;
await new Promise<void>((resolve) => setImmediate(resolve));
}
}
finishStoredZip(fd, state);
} catch (error) {
fs.closeSync(fd);
fs.rmSync(outputPath, { force: true });
throw error;
}
fs.closeSync(fd);
return { entryCount: state.entries.length };
}
-22
View File
@@ -50,24 +50,6 @@ export interface StatsKnownWordsSummary {
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 {
id: number;
episodes: number | null;
@@ -182,8 +164,6 @@ export interface StatsJsonResponseMap {
sessionEvents: SessionEvent[];
sessionKnownWordsTimeline: StatsSessionKnownWordsTimelinePoint[];
vocabulary: VocabularyEntry[];
vocabularySummary: StatsVocabularySummary;
vocabularyCharts: StatsVocabularyCharts;
excludedWords: StatsExcludedWord[];
setExcludedWords: StatsOkResponse;
duplicateLineCleanup: StatsDuplicateLineCleanupResult;
@@ -242,8 +222,6 @@ export interface StatsHttpClient {
getSessionEvents: (id: number, limit?: number, eventTypes?: number[]) => Promise<SessionEvent[]>;
getSessionKnownWordsTimeline: (id: number) => Promise<StatsSessionKnownWordsTimelinePoint[]>;
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
getVocabularySummary: () => Promise<StatsVocabularySummary>;
getVocabularyCharts: () => Promise<StatsVocabularyCharts>;
getExcludedWords: () => Promise<StatsExcludedWord[]>;
setExcludedWords: (words: StatsExcludedWord[]) => Promise<void>;
cleanupDuplicateLines: (
+118 -46
View File
@@ -1,4 +1,4 @@
import { execFileSync } from 'node:child_process';
import { execFile } from 'node:child_process';
import koffi from 'koffi';
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
@@ -173,43 +173,125 @@ function getProcessNameByPid(pid: number): string | null {
}
}
const processCommandLineCache = new Map<number, string>();
// Short-lived cache so the 250ms poll doesn't re-query every top-level window's process
// 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 {
if (processCommandLineCache.has(pid)) {
return processCommandLineCache.get(pid) ?? null;
const entry = processCommandLineCache.get(pid);
const nowMs = Date.now();
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;
}
let commandLine: string | null = 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;
}
if (entry?.state === 'pending') return null;
if (entry?.state === 'failed' && nowMs < entry.retryAtMs) return null;
if (commandLine !== null) {
processCommandLineCache.set(pid, commandLine);
} else {
processCommandLineCache.delete(pid);
}
return commandLine;
const nextBackoffMs =
entry?.state === 'failed'
? Math.min(entry.backoffMs * 2, COMMAND_LINE_RETRY_MAX_BACKOFF_MS)
: COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS;
processCommandLineCache.set(pid, { state: 'pending' });
queryProcessCommandLine(pid, (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 {
@@ -217,8 +299,7 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
const matches: MpvWindowMatch[] = [];
let hasMinimized = false;
let hasFocused = false;
const processNameCache = new Map<number, string | null>();
const processCommandLineLookupCache = new Map<number, string | null>();
pruneExpiredProcessNames(Date.now());
const cb = koffi.register((hwnd: number, _lParam: number) => {
if (!IsWindowVisible(hwnd)) return true;
@@ -228,21 +309,12 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
const pidValue = pid[0]!;
if (pidValue === 0) return true;
let processName = processNameCache.get(pidValue);
if (processName === undefined) {
processName = getProcessNameByPid(pidValue);
processNameCache.set(pidValue, processName);
}
const processName = getCachedProcessNameByPid(pidValue);
if (!processName || processName.toLowerCase() !== 'mpv') return true;
let commandLine: string | null = null;
if (targetSocketPath) {
commandLine = processCommandLineLookupCache.get(pidValue) ?? null;
if (!processCommandLineLookupCache.has(pidValue)) {
commandLine = getProcessCommandLineByPid(pidValue);
processCommandLineLookupCache.set(pidValue, commandLine);
}
commandLine = getProcessCommandLineByPid(pidValue);
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
return true;
}
@@ -98,10 +98,10 @@ export function DuplicateLineCleanup({ onClose, onCleaned }: DuplicateLineCleanu
<div className="space-y-4 px-5 py-4">
<p className="text-xs leading-relaxed text-ctp-subtext0">
Karaoke openings and animated signs are typeset as one subtitle event per animation
frame, and older versions counted every frame as a line. This collapses those runs back
to one line and drops the word and kanji counts they added. Repeated dialogue is left
alone.
Typeset subtitles karaoke openings, animated signs are authored as one event per
animation frame, and older versions counted every frame as its own line. This finds
those runs and collapses each one back to a single line, giving back the word and kanji
counts they inflated. Ordinary repeated dialogue is left alone.
</p>
<div>
@@ -192,8 +192,8 @@ export function DuplicateLineCleanup({ onClose, onCleaned }: DuplicateLineCleanu
</button>
</div>
<p className="text-[11px] text-ctp-overlay1">
Scan first: cleanup deletes rows and can't be undone. Watch time and lines-seen totals
stay as they are.
Scan first: cleanup removes rows and cannot be undone. Session watch time and lines-seen
totals are left untouched.
</p>
</div>
</div>
@@ -6,10 +6,11 @@ import { KanjiBreakdown } from './KanjiBreakdown';
import { KanjiDetailPanel } from './KanjiDetailPanel';
import { ExclusionManager } from './ExclusionManager';
import { DuplicateLineCleanup } from './DuplicateLineCleanup';
import { epochDayToDate, formatNumber } from '../../lib/formatters';
import { formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart';
import { FrequencyRankTable } from './FrequencyRankTable';
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import { buildVocabularySummary } from '../../lib/dashboard-data';
import type { ExcludedWord } from '../../hooks/useExcludedWords';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
@@ -34,18 +35,7 @@ export function VocabularyTab({
onRemoveExclusion,
onClearExclusions,
}: VocabularyTabProps) {
const {
words,
kanji,
knownWords,
summary,
charts,
loading,
error,
aggregatesError,
refreshAggregates,
reload,
} = useVocabulary();
const { words, kanji, knownWords, loading, error, reload } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [hideNames, setHideNames] = useState(false);
const [showExclusionManager, setShowExclusionManager] = useState(false);
@@ -58,26 +48,19 @@ export function VocabularyTab({
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
return result;
}, [words, hideNames, excluded, isExcluded]);
const chartData = useMemo(
() => ({
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 summary = useMemo(
() => buildVocabularySummary(filteredWords, kanji),
[filteredWords, kanji],
);
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) {
return (
@@ -99,9 +82,7 @@ export function VocabularyTab({
};
const handleBarClick = (headword: string): void => {
const match = (hideNames ? charts?.topWordsWithoutNames : charts?.topWords)?.find(
(word) => word.headword === headword,
);
const match = filteredWords.find((w) => w.headword === headword);
if (match) onOpenWordDetail?.(match.wordId);
};
@@ -109,60 +90,33 @@ export function VocabularyTab({
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
<StatCard
label="Unique Words"
value={summary ? formatNumber(displayedSummary.uniqueWords) : '…'}
value={formatNumber(summary.uniqueWords)}
color="text-ctp-blue"
/>
{displayedSummary.knownWordCount !== null ? (
{knownWords.size > 0 && (
<StatCard
label="Known Words"
value={`${formatNumber(displayedSummary.knownWordCount)} (${displayedSummary.uniqueWords > 0 ? Math.round((displayedSummary.knownWordCount / displayedSummary.uniqueWords) * 100) : 0}%)`}
value={`${formatNumber(knownWordCount)} (${summary.uniqueWords > 0 ? Math.round((knownWordCount / summary.uniqueWords) * 100) : 0}%)`}
color="text-ctp-green"
/>
) : knownWords.size > 0 ? (
<StatCard label="Known Words" value="…" color="text-ctp-green" />
) : null}
)}
<StatCard
label="Unique Kanji"
value={summary ? formatNumber(summary.uniqueKanji) : '…'}
value={formatNumber(summary.uniqueKanji)}
color="text-ctp-teal"
/>
<StatCard
label="New This Week"
value={summary ? `+${formatNumber(displayedSummary.newThisWeek)}` : '…'}
value={`+${formatNumber(summary.newThisWeek)}`}
color="text-ctp-mauve"
/>
</div>
{aggregatesError && (
<p className="text-xs text-ctp-red" role="alert">
{aggregatesError}{' '}
<button
type="button"
onClick={refreshAggregates}
className="underline hover:text-ctp-text"
>
Retry
</button>
</p>
)}
<div className="flex items-center justify-end gap-3">
{hasNames && (
<button
@@ -200,25 +154,19 @@ export function VocabularyTab({
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<TrendChart
title="Top Repeated Words"
data={chartData.topWords}
data={summary.topWords}
color="#8aadf4"
type="bar"
onBarClick={handleBarClick}
/>
<TrendChart
title="New Words by Day"
data={chartData.newWordsTimeline}
data={summary.newWordsTimeline}
color="#c6a0f6"
type="line"
/>
</div>
{charts && !charts.ready && (
<p className="text-xs text-ctp-overlay1" role="status">
Building vocabulary history in the background
</p>
)}
<FrequencyRankTable
words={filteredWords}
knownWords={knownWords}
-89
View File
@@ -6,7 +6,6 @@ import {
initializeExcludedWordsStore,
resetExcludedWordsStoreForTests,
setExcludedWords,
subscribeExcludedWordsServerSync,
} from './useExcludedWords';
import { BASE_URL } from '../lib/api-client';
@@ -200,91 +199,3 @@ test('initializeExcludedWordsStore retries after transient database load failure
resetExcludedWordsStoreForTests();
}
});
test('a failing server-sync listener neither rolls back the write nor blocks other listeners', async () => {
resetExcludedWordsStoreForTests();
const { values: storage, restore } = installLocalStorage();
const originalFetch = globalThis.fetch;
const originalConsoleError = console.error;
console.error = () => {};
globalThis.fetch = (async () =>
new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch;
const notified: string[] = [];
const unsubscribeFirst = subscribeExcludedWordsServerSync(() => {
notified.push('first');
throw new Error('listener exploded');
});
const unsubscribeSecond = subscribeExcludedWordsServerSync(() => {
notified.push('second');
});
try {
const rows = [{ headword: 'する', word: 'する', reading: 'する' }];
await assert.doesNotReject(() => setExcludedWords(rows));
assert.deepEqual(notified, ['first', 'second']);
assert.deepEqual(getExcludedWordsSnapshot(), rows);
assert.equal(storage.get(STORAGE_KEY), JSON.stringify(rows));
} finally {
unsubscribeFirst();
unsubscribeSecond();
globalThis.fetch = originalFetch;
console.error = originalConsoleError;
restore();
resetExcludedWordsStoreForTests();
}
});
test('overlapping writes serialize so an older list cannot overwrite a newer edit', async () => {
resetExcludedWordsStoreForTests();
const { restore } = installLocalStorage();
const originalFetch = globalThis.fetch;
const sentBodies: string[] = [];
let releaseFirst: (() => void) | null = null;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
sentBodies.push(String(init?.body ?? ''));
if (sentBodies.length === 1) {
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}) as typeof globalThis.fetch;
const syncs: string[] = [];
const unsubscribe = subscribeExcludedWordsServerSync(() => {
syncs.push(JSON.stringify(getExcludedWordsSnapshot()));
});
try {
const first = [{ headword: '猫', word: '猫', reading: 'ねこ' }];
const second = [...first, { headword: '犬', word: '犬', reading: 'いぬ' }];
const third = [...second, { headword: '鳥', word: '鳥', reading: 'とり' }];
// The first write reaches the network before the later edits are made.
const firstWrite = setExcludedWords(first);
await new Promise((resolve) => setTimeout(resolve, 0));
const secondWrite = setExcludedWords(second);
const thirdWrite = setExcludedWords(third);
const release = releaseFirst as (() => void) | null;
assert.ok(release, 'expected the first write to be in flight');
release();
await Promise.all([firstWrite, secondWrite, thirdWrite]);
// The in-flight write finishes first, the superseded middle write is
// dropped, and the newest list is the last thing the server is told.
assert.deepEqual(sentBodies, [
JSON.stringify({ words: first }),
JSON.stringify({ words: third }),
]);
assert.deepEqual(getExcludedWordsSnapshot(), third);
// Only the final revision notifies: the first write's acknowledgement was
// already obsolete, so it must not trigger an aggregate recomputation.
assert.deepEqual(syncs, [JSON.stringify(third)]);
} finally {
unsubscribe();
globalThis.fetch = originalFetch;
restore();
resetExcludedWordsStoreForTests();
}
});
+9 -48
View File
@@ -44,32 +44,6 @@ let cachedKeys: Set<string> | null = null;
let initialized: Promise<void> | null = null;
let revision = 0;
const listeners = new Set<() => void>();
// Fires only after the stats server acknowledged an exclusion write, so
// subscribers can refetch server-computed aggregates without racing the POST.
const serverSyncListeners = new Set<() => void>();
export function subscribeExcludedWordsServerSync(fn: () => void): () => void {
serverSyncListeners.add(fn);
return () => {
serverSyncListeners.delete(fn);
};
}
function notifyServerSync(): void {
// Listener failures are their own concern: one must not roll back a write
// that already succeeded, nor stop the remaining listeners from running.
for (const fn of serverSyncListeners) {
try {
fn();
} catch (error) {
console.error('Excluded words server-sync listener failed', error);
}
}
}
// Full-list writes are serialized so a slow earlier request cannot land after a
// newer one and overwrite it with a stale list.
let writeChain: Promise<void> = Promise.resolve();
function readLocalStorage(): ExcludedWord[] {
if (typeof localStorage === 'undefined') return [];
@@ -128,27 +102,16 @@ export async function setExcludedWords(words: ExcludedWord[]): Promise<void> {
const normalized = dedupeExcludedWords(words);
revision = writeRevision;
applyWords(normalized);
const write = writeChain.then(async () => {
// A newer edit already superseded this list and carries the newest state,
// so sending this one would push a stale list to the server.
if (revision !== writeRevision) return;
try {
await apiClient.setExcludedWords(normalized);
} catch (error) {
if (revision === writeRevision) {
revision = previousRevision;
applyWords(previousWords);
}
console.error('Failed to persist excluded words to stats database', error);
throw error;
try {
await apiClient.setExcludedWords(normalized);
} catch (error) {
if (revision === writeRevision) {
revision = previousRevision;
applyWords(previousWords);
}
// A newer edit arrived while this write was in flight, so the server state
// this acknowledges is already obsolete. Its own acknowledgement notifies
// with the newest list; skipping here avoids a wasted aggregate scan.
if (revision === writeRevision) notifyServerSync();
});
writeChain = write.catch(() => {});
return write;
console.error('Failed to persist excluded words to stats database', error);
throw error;
}
}
export function initializeExcludedWordsStore(): Promise<void> {
@@ -192,8 +155,6 @@ export function resetExcludedWordsStoreForTests(): void {
initialized = null;
revision = 0;
listeners.clear();
serverSyncListeners.clear();
writeChain = Promise.resolve();
}
function subscribe(fn: () => void): () => void {
-399
View File
@@ -1,399 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { apiClient } from '../lib/api-client';
import { resetExcludedWordsStoreForTests, setExcludedWords } from './useExcludedWords';
import { useVocabulary } from './useVocabulary';
import type { StatsVocabularyCharts, StatsVocabularySummary } from '../types/stats';
type VocabularyState = ReturnType<typeof useVocabulary>;
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousIsReactActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
const window = new Window();
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
globals.IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
globals.IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
};
}
function installLocalStorage(): () => void {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
return () => {
if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
else delete (globalThis as { localStorage?: unknown }).localStorage;
};
}
interface FakeClock {
tick: (ms: number) => void;
restore: () => void;
}
/** Bun's `node:test` shim has no `mock.timers`, so the retry clock is faked here. */
function installFakeTimers(): FakeClock {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const timers = new Map<number, { at: number; fn: () => void }>();
let now = 0;
let nextId = 1;
globalThis.setTimeout = ((fn: () => void, delay = 0) => {
const id = nextId;
nextId += 1;
timers.set(id, { at: now + delay, fn });
return id;
}) as unknown as typeof globalThis.setTimeout;
globalThis.clearTimeout = ((id: number) => {
timers.delete(id);
}) as unknown as typeof globalThis.clearTimeout;
return {
tick: (ms: number) => {
now += ms;
const due = [...timers.entries()]
.filter(([, timer]) => timer.at <= now)
.sort(([, a], [, b]) => a.at - b.at);
for (const [id, timer] of due) {
timers.delete(id);
timer.fn();
}
},
restore: () => {
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
},
};
}
function summaryFixture(): StatsVocabularySummary {
return {
uniqueWords: 42,
uniqueWordsWithoutNames: 40,
uniqueKanji: 7,
newThisWeek: 3,
newThisWeekWithoutNames: 2,
knownWordCount: 10,
knownWordCountWithoutNames: 9,
};
}
function chartsFixture(overrides: Partial<StatsVocabularyCharts> = {}): StatsVocabularyCharts {
return {
ready: true,
topWords: [{ wordId: 1, headword: '猫', frequency: 5 }],
topWordsWithoutNames: [{ wordId: 1, headword: '猫', frequency: 5 }],
newWordsTimeline: [{ epochDay: 20_000, wordCount: 4 }],
newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 4 }],
...overrides,
};
}
interface Harness {
state: () => VocabularyState;
flush: () => Promise<void>;
tick: (ms: number) => Promise<void>;
unmount: () => Promise<void>;
teardown: () => Promise<void>;
}
async function mountHook(): Promise<Harness> {
const uninstallDom = installDom();
const uninstallLocalStorage = installLocalStorage();
const clock = installFakeTimers();
let latest: VocabularyState | null = null;
function Probe() {
latest = useVocabulary();
return null;
}
const container = document.createElement('div');
document.body.append(container);
let root: Root | null = createRoot(container);
await act(async () => {
root!.render(<Probe />);
});
const flush = async (): Promise<void> => {
// Drain promise callbacks without advancing the mocked clock.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
return {
state: () => {
assert.ok(latest, 'expected the hook to have rendered');
return latest;
},
flush,
tick: async (ms: number) => {
await act(async () => {
clock.tick(ms);
});
await flush();
},
unmount: async () => {
await act(async () => {
root?.unmount();
root = null;
});
},
teardown: async () => {
await act(async () => {
root?.unmount();
root = null;
});
clock.restore();
// React's scheduler can still have deferred work queued; let it drain on
// a real timer while the DOM globals it reads are still installed.
await new Promise((resolve) => setTimeout(resolve, 0));
uninstallLocalStorage();
uninstallDom();
resetExcludedWordsStoreForTests();
},
};
}
function stubVocabularyClient(overrides: {
getVocabularySummary: () => Promise<StatsVocabularySummary>;
getVocabularyCharts: () => Promise<StatsVocabularyCharts>;
}): () => void {
const original = {
getVocabulary: apiClient.getVocabulary,
getKanji: apiClient.getKanji,
getKnownWords: apiClient.getKnownWords,
getVocabularySummary: apiClient.getVocabularySummary,
getVocabularyCharts: apiClient.getVocabularyCharts,
setExcludedWords: apiClient.setExcludedWords,
};
apiClient.getVocabulary = async () => [];
apiClient.getKanji = async () => [];
apiClient.getKnownWords = async () => [];
apiClient.setExcludedWords = async () => {};
apiClient.getVocabularySummary = overrides.getVocabularySummary;
apiClient.getVocabularyCharts = overrides.getVocabularyCharts;
return () => Object.assign(apiClient, original);
}
test('aggregate failures retry with backoff, then surface an error that Retry clears', async () => {
const originalConsoleError = console.error;
console.error = () => {};
let summaryCalls = 0;
let failSummary = true;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => {
summaryCalls += 1;
if (failSummary) throw new Error('summary unavailable');
return summaryFixture();
},
getVocabularyCharts: async () => chartsFixture(),
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(summaryCalls, 1);
assert.equal(harness.state().aggregatesError, null, 'no error until retries are exhausted');
// Backoff is 1s, 2s, 4s, 8s across the remaining four attempts.
for (const delayMs of [1_000, 2_000, 4_000, 8_000]) {
await harness.tick(delayMs);
}
assert.equal(summaryCalls, 5, 'retries are bounded at the attempt limit');
assert.match(harness.state().aggregatesError ?? '', /totals failed to load/i);
// Nothing further is scheduled once the limit is reached.
await harness.tick(60_000);
assert.equal(summaryCalls, 5);
failSummary = false;
await act(async () => {
harness.state().refreshAggregates();
});
await harness.flush();
assert.equal(summaryCalls, 6);
assert.equal(harness.state().aggregatesError, null);
assert.deepEqual(harness.state().summary, summaryFixture());
} finally {
await harness.teardown();
restoreClient();
console.error = originalConsoleError;
}
});
test('charts poll while the backfill is pending and stop once it is ready', async () => {
let chartCalls = 0;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => summaryFixture(),
getVocabularyCharts: async () => {
chartCalls += 1;
return chartsFixture({ ready: chartCalls >= 3 });
},
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(chartCalls, 1);
assert.equal(harness.state().charts?.ready, false);
await harness.tick(1_000);
assert.equal(chartCalls, 2);
await harness.tick(1_000);
assert.equal(chartCalls, 3);
assert.equal(harness.state().charts?.ready, true);
// A ready result ends the poll.
await harness.tick(60_000);
assert.equal(chartCalls, 3);
} finally {
await harness.teardown();
restoreClient();
}
});
test('aggregates refetch after an exclusion edit is acknowledged by the server', async () => {
let summaryCalls = 0;
let chartCalls = 0;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => {
summaryCalls += 1;
return summaryFixture();
},
getVocabularyCharts: async () => {
chartCalls += 1;
return chartsFixture();
},
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(summaryCalls, 1);
assert.equal(chartCalls, 1);
await act(async () => {
await setExcludedWords([{ headword: '猫', word: '猫', reading: 'ねこ' }]);
});
await harness.flush();
assert.equal(summaryCalls, 2, 'totals must not keep counting the excluded word');
assert.equal(chartCalls, 2);
} finally {
await harness.teardown();
restoreClient();
}
});
test('pending retries are cancelled when the tab unmounts', async () => {
const originalConsoleError = console.error;
console.error = () => {};
let summaryCalls = 0;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => {
summaryCalls += 1;
throw new Error('summary unavailable');
},
getVocabularyCharts: async () => chartsFixture(),
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(summaryCalls, 1);
await harness.unmount();
await harness.tick(60_000);
assert.equal(summaryCalls, 1, 'no retry may run after unmount');
} finally {
await harness.teardown();
restoreClient();
console.error = originalConsoleError;
}
});
test('a slow response from a superseded refresh cannot replace the newest aggregates', async () => {
let summaryCalls = 0;
let releaseSuperseded: (() => void) | null = null;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => {
summaryCalls += 1;
const call = summaryCalls;
// The second call is the one that gets superseded while still in flight.
if (call === 2) {
await new Promise<void>((resolve) => {
releaseSuperseded = resolve;
});
}
return { ...summaryFixture(), uniqueWords: call };
},
getVocabularyCharts: async () => chartsFixture(),
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(harness.state().summary?.uniqueWords, 1);
// First refresh stalls, then a second refresh supersedes it and resolves.
await act(async () => {
harness.state().refreshAggregates();
});
await harness.flush();
await act(async () => {
harness.state().refreshAggregates();
});
await harness.flush();
assert.equal(summaryCalls, 3);
assert.equal(harness.state().summary?.uniqueWords, 3);
const release = releaseSuperseded as (() => void) | null;
assert.ok(release, 'expected the superseded request to still be in flight');
release();
await harness.flush();
assert.equal(
harness.state().summary?.uniqueWords,
3,
'the superseded response must not overwrite the newest totals',
);
} finally {
await harness.teardown();
restoreClient();
}
});
+3 -107
View File
@@ -1,45 +1,16 @@
import { useState, useEffect, useCallback } from 'react';
import { getStatsClient } from './useStatsApi';
import { subscribeExcludedWordsServerSync } from './useExcludedWords';
import type {
VocabularyEntry,
KanjiEntry,
StatsVocabularyCharts,
StatsVocabularySummary,
} from '../types/stats';
const AGGREGATE_RETRY_BASE_MS = 1_000;
const AGGREGATE_RETRY_MAX_MS = 30_000;
const AGGREGATE_RETRY_LIMIT = 5;
const CHART_BACKFILL_POLL_MS = 1_000;
const CHART_BACKFILL_SLOW_POLL_MS = 5_000;
const CHART_BACKFILL_FAST_POLLS = 30;
function aggregateRetryDelayMs(attempt: number): number {
return Math.min(AGGREGATE_RETRY_BASE_MS * 2 ** attempt, AGGREGATE_RETRY_MAX_MS);
}
import type { VocabularyEntry, KanjiEntry } from '../types/stats';
export function useVocabulary() {
const [words, setWords] = useState<VocabularyEntry[]>([]);
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
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 [error, setError] = useState<string | null>(null);
const [aggregatesError, setAggregatesError] = useState<string | null>(null);
// Bumped by `reload` after maintenance rewrites the vocabulary tables.
const [reloadToken, setReloadToken] = useState(0);
// Bumped independently when only the server-computed summary/charts are
// stale, e.g. after the exclusion list changes on the server.
const [aggregatesToken, setAggregatesToken] = useState(0);
const refreshAggregates = useCallback(() => setAggregatesToken((token) => token + 1), []);
const reload = useCallback(() => {
setReloadToken((token) => token + 1);
setAggregatesToken((token) => token + 1);
}, []);
useEffect(() => subscribeExcludedWordsServerSync(refreshAggregates), [refreshAggregates]);
const reload = useCallback(() => setReloadToken((token) => token + 1), []);
useEffect(() => {
let cancelled = false;
@@ -80,80 +51,5 @@ export function useVocabulary() {
};
}, [reloadToken]);
useEffect(() => {
let cancelled = false;
setAggregatesError(null);
const client = getStatsClient();
const timers = new Set<ReturnType<typeof setTimeout>>();
const schedule = (fn: () => void, delayMs: number): void => {
const timer = setTimeout(() => {
timers.delete(timer);
fn();
}, delayMs);
timers.add(timer);
};
const loadSummary = (attempt: number): void => {
void client
.getVocabularySummary()
.then((nextSummary) => {
if (!cancelled) setSummary(nextSummary);
})
.catch((summaryError: unknown) => {
console.error('Failed to load vocabulary summary', summaryError);
if (cancelled) return;
if (attempt + 1 < AGGREGATE_RETRY_LIMIT) {
schedule(() => loadSummary(attempt + 1), aggregateRetryDelayMs(attempt));
} else {
setAggregatesError((previous) => previous ?? 'Vocabulary totals failed to load.');
}
});
};
const loadCharts = (attempt: number, readyPolls: number): void => {
void client
.getVocabularyCharts()
.then((nextCharts) => {
if (cancelled) return;
setCharts(nextCharts);
if (!nextCharts.ready) {
schedule(
() => loadCharts(0, readyPolls + 1),
readyPolls < CHART_BACKFILL_FAST_POLLS
? CHART_BACKFILL_POLL_MS
: CHART_BACKFILL_SLOW_POLL_MS,
);
}
})
.catch((chartError: unknown) => {
console.error('Failed to load vocabulary charts', chartError);
if (cancelled) return;
if (attempt + 1 < AGGREGATE_RETRY_LIMIT) {
schedule(() => loadCharts(attempt + 1, readyPolls), aggregateRetryDelayMs(attempt));
} else {
setAggregatesError((previous) => previous ?? 'Vocabulary charts failed to load.');
}
});
};
loadSummary(0);
loadCharts(0, 0);
return () => {
cancelled = true;
for (const timer of timers) clearTimeout(timer);
};
}, [aggregatesToken]);
return {
words,
kanji,
knownWords,
summary,
charts,
loading,
error,
aggregatesError,
refreshAggregates,
reload,
};
return { words, kanji, knownWords, loading, error, reload };
}
-2
View File
@@ -100,8 +100,6 @@ export const apiClient = {
getSessionKnownWordsTimeline: (id: number) =>
fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`),
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'),
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
await fetchResponse('/api/stats/excluded-words', {
+1 -19
View File
@@ -1,12 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
epochDayToDate,
epochMsFromDbTimestamp,
formatRelativeDate,
formatSessionDayLabel,
} from './formatters';
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
const FIXED_NOW = new Date(2026, 2, 16, 12, 0, 0).getTime();
@@ -113,19 +108,6 @@ test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => {
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', () => {
withFixedNow((now) => {
const oneDayMs = 24 * 60 * 60_000;
+1 -2
View File
@@ -38,8 +38,7 @@ export function formatRelativeDate(ms: number): string {
}
export function epochDayToDate(epochDay: number): Date {
const utcDate = new Date(epochDay * 86_400_000);
return new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate());
return new Date(epochDay * 86_400_000);
}
export function localDayFromMs(ms: number): number {
+6 -24
View File
@@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url';
const VOCABULARY_TAB_PATH = fileURLToPath(
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', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
@@ -21,32 +20,15 @@ test('VocabularyTab declares all hooks before loading and error early returns',
assert.deepEqual(hooksAfterLoadingGuard ?? [], []);
});
test('VocabularyTab uses uncapped server-side data for its charts and card totals', () => {
test('VocabularyTab memoizes summary and known-word aggregate calculations', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
assert.match(source, /\} = 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('VocabularyTab surfaces aggregate failures with a retry control', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
assert.match(source, /aggregatesError/);
assert.match(source, /onClick=\{refreshAggregates\}/);
});
test('useVocabulary loads exact card totals without holding up the vocabulary tables', () => {
const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8');
assert.match(
source,
/Promise\.allSettled\(\[\s*client\.getVocabulary\(500\),\s*client\.getKanji\(200\),\s*client\.getKnownWords\(\),?\s*\]\)/,
/const summary = useMemo\([\s\S]*buildVocabularySummary\(filteredWords, kanji\)[\s\S]*\[filteredWords, kanji\][\s\S]*\);/,
);
assert.match(
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\]\);/,
);
assert.match(source, /client\s*\.getVocabularySummary\(\)\s*\.then\(/);
assert.match(source, /client\s*\.getVocabularyCharts\(\)/);
});