Compare commits

..

12 Commits

Author SHA1 Message Date
sudacode 8b0ef662bc chore: remove shipped changelog entries and demo asset
- Drop changes/kiku-field-grouping-modal-wiring.md (landed in #138)
- Drop changes/known-word-partial-reading-highlight.md (landed in #142)
- Remove assets/minecard.mkv demo video
2026-07-07 00:18:48 -07:00
sudacode 0a58c20ad7 fix(stats): start stats server on background app launch (#144) 2026-07-07 00:16:57 -07:00
sudacode 38ddb29aa0 feat(anki): reading-aware known-word matching (cache v3) (#142) 2026-07-07 00:13:10 -07:00
sudacode 8b9a70c5a6 feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips (#140) 2026-07-06 23:52:43 -07:00
sudacode 48a084914a feat(branding): replace app icon with contributed pixel-art set (#141) 2026-07-06 23:31:19 -07:00
sudacode a042b04357 fix(overlay): prevent field grouping modal from freezing overlay on Hyprland (#138) 2026-07-06 22:13:14 -07:00
sudacode 35ca2afc6f fix(overlay): collapse karaoke syllable spam in secondary subtitles (#139) 2026-07-06 01:09:06 -07:00
sudacode b14f977e33 fix(tokenizer): stop partial furigana readings from marking words known (#137) 2026-07-05 17:02:44 -07:00
sudacode eef4500599 feat(launcher): add -H/--history command to browse local watch history (#136) 2026-07-05 16:46:05 -07:00
sudacode 73af1451b7 fix(anki): lower audio normalization target to -23 LUFS (EBU R128) 2026-07-05 01:13:54 -07:00
sudacode 36a3704815 fix(youtube): parse mpv EDL stream URLs with byte-length guards (#134) 2026-07-02 01:37:37 -07:00
sudacode 359cb0a301 Normalize generated Anki audio by default (#135) 2026-07-02 00:34:16 -07:00
127 changed files with 4556 additions and 399 deletions
+1
View File
@@ -213,6 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
subminer video.mkv # launch mpv with SubMiner
subminer /path/to/dir # pick a file with fzf
subminer -R /path/to/dir # pick a file with rofi (Linux only)
subminer -H # browse local watch history (replay / next episode / browse)
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

After

Width:  |  Height:  |  Size: 549 B

Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: mining
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- `subminer app` background launches now start the stats server automatically when `stats.autoStartServer` is enabled, and skip startup when a background stats server is already running.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: launcher
- Show cover art icons in the rofi watch-history picker, reusing AniList covers already stored in the stats database (extracted to `~/.cache/subminer/covers`).
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message.
@@ -0,0 +1,6 @@
type: fixed
area: overlay
- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible.
- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled".
- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" matching a known 床/ゆか "floor" card). The known-word cache now stores each card's word together with its reading and only matches when the token's reading agrees; cards without a reading field keep matching in any reading as before.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: launcher
- Added `subminer -H` / `--history` to browse local watch history, replay the last watched episode, continue to the next episode, or browse episodes with fzf/rofi.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: branding
- Replaced the SubMiner app icon with new pixel-art submarine artwork contributed by an anonymous community member, used across the app icon, tray, notifications, README, docs site, and stats page.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed secondary subtitles stacking dozens of one-syllable lines down the screen during karaoke-typeset openings/endings, which made the hover-pause band cover the whole video: karaoke-like event spam is now collapsed into a single deduped line, and the secondary subtitle area is height-capped so it always stays a strip at the top.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors.
+2 -1
View File
@@ -559,6 +559,7 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
@@ -568,7 +569,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
+3 -1
View File
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
"ankiConnect": {
"media": {
"generateAudio": true,
"normalizeAudio": true, // normalize generated clip loudness
"audioPadding": 0, // optional seconds before and after subtitle timing
"maxMediaDuration": 30 // cap total duration in seconds
}
}
```
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness.
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
@@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"imageType": "static",
"imageFormat": "jpg",
"imageQuality": 92,
"normalizeAudio": true,
"audioPadding": 0,
"maxMediaDuration": 30,
},
+2
View File
@@ -951,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
"animatedMaxWidth": 640,
"animatedMaxHeight": 0,
"animatedCrf": 35,
"normalizeAudio": true,
"audioPadding": 0,
"fallbackDuration": 3,
"maxMediaDuration": 30
@@ -1001,6 +1002,7 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
+1 -1
View File
@@ -95,7 +95,7 @@ Stats server config lives under `stats`:
- `toggleKey` is overlay-local, not a system-wide shortcut.
- `serverPort` controls the localhost dashboard URL.
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running.
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
+18
View File
@@ -61,6 +61,23 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
```
## Watch History
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
```bash
subminer -H # fzf history browser
subminer -R -H # rofi history browser
```
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
- **Replay last watched** — replays the most recently watched episode
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
## Common Commands
```bash
@@ -105,6 +122,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
| `--setup` | Open first-run setup popup manually |
| `-v, --version` | Print installed SubMiner version |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 15 KiB

+2 -1
View File
@@ -559,6 +559,7 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
@@ -568,7 +569,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+30
View File
@@ -0,0 +1,30 @@
// Minimal ambient typing for bun:sqlite. The launcher always runs under bun
// (see the build banner in package.json), but the repo typechecks with plain
// tsc which has no bun type definitions.
declare module 'bun:sqlite' {
export interface RunResult {
changes: number;
lastInsertRowid: number | bigint;
}
export interface Statement<ReturnType = unknown, ParamsType extends unknown[] = unknown[]> {
all(...params: ParamsType): ReturnType[];
get(...params: ParamsType): ReturnType | undefined;
run(...params: ParamsType): RunResult;
}
export class Database {
constructor(
filename: string,
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
);
query<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
sql: string,
): Statement<ReturnType, ParamsType>;
prepare<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
sql: string,
): Statement<ReturnType, ParamsType>;
run(sql: string, ...params: unknown[]): RunResult;
close(throwOnError?: boolean): void;
}
}
+228
View File
@@ -0,0 +1,228 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fail, log } from '../log.js';
import { commandExists } from '../util.js';
import {
collectVideos,
findRofiTheme,
formatPickerLaunchError,
showFzfMenu,
showRofiMenu,
} from '../picker.js';
import {
findNextEpisode,
groupHistoryBySeries,
listSeasonDirs,
materializeCoverArt,
queryLocalWatchHistory,
resolveImmersionDbPath,
sortVideosByEpisode,
type HistorySeriesEntry,
} from '../history.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
function checkPickerDependencies(args: Args): void {
if (args.useRofi) {
if (!commandExists('rofi')) fail('Missing dependency: rofi');
return;
}
if (!commandExists('fzf')) fail('Missing dependency: fzf');
}
function showRofiIndexMenu(
labels: string[],
prompt: string,
themePath: string | null,
icons: Array<string | null> = [],
): number {
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
const hasIcons = icons.some(Boolean);
if (hasIcons) rofiArgs.push('-show-icons');
if (themePath) {
rofiArgs.push('-theme', themePath);
} else {
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
}
if (hasIcons) {
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
}
const lines = labels.map((label, index) =>
icons[index] ? `${label}\u0000icon\u001f${icons[index]}` : label,
);
const result = spawnSync('rofi', rofiArgs, {
input: `${lines.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) {
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
const out = (result.stdout || '').trim();
if (!out) return -1;
const idx = Number.parseInt(out, 10);
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
}
function showFzfIndexMenu(labels: string[], prompt: string): number {
const lines = labels.map((label, index) => `${index}\t${label}`);
const result = spawnSync(
'fzf',
[
'--ansi',
'--reverse',
'--ignore-case',
`--prompt=${prompt}: `,
'--delimiter=\t',
'--with-nth=2..',
],
{
input: `${lines.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
},
);
if (result.error) {
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
}
const picked = (result.stdout || '').trim();
const tab = picked.indexOf('\t');
if (tab === -1) return -1;
const idx = Number.parseInt(picked.slice(0, tab), 10);
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
}
function pickIndex(
labels: string[],
prompt: string,
useRofi: boolean,
themePath: string | null,
icons: Array<string | null> = [],
): number {
if (labels.length === 0) return -1;
return useRofi
? showRofiIndexMenu(labels, prompt, themePath, icons)
: showFzfIndexMenu(labels, prompt);
}
function formatEpisodeLabel(entry: HistorySeriesEntry): string {
const { parsedSeason, parsedEpisode } = entry.lastWatched;
if (parsedEpisode === null) return '';
return parsedSeason !== null ? `S${parsedSeason}E${parsedEpisode}` : `E${parsedEpisode}`;
}
function formatSeriesLabel(entry: HistorySeriesEntry): string {
const episodeLabel = formatEpisodeLabel(entry);
return episodeLabel ? `${entry.displayName} [last: ${episodeLabel}]` : entry.displayName;
}
function pickEpisodeFromDir(dir: string, context: LauncherCommandContext): string | null {
const { args, scriptPath } = context;
const videos = sortVideosByEpisode(collectVideos(dir, false));
if (videos.length === 0) {
fail(`No video files found in: ${dir}`);
}
const selected = args.useRofi
? showRofiMenu(videos, dir, false, scriptPath, args.logLevel)
: showFzfMenu(videos);
return selected || null;
}
function browseEpisodes(
entry: HistorySeriesEntry,
context: LauncherCommandContext,
themePath: string | null,
): string | null {
const { args } = context;
const seasons = listSeasonDirs(entry.seriesRoot);
let dir = entry.seriesRoot;
if (seasons.length > 1) {
const idx = pickIndex(
seasons.map((season) => season.name),
`${entry.displayName} — Season`,
args.useRofi,
themePath,
);
if (idx < 0) return null;
dir = seasons[idx]!.path;
} else if (seasons.length === 1 && collectVideos(dir, false).length === 0) {
dir = seasons[0]!.path;
}
return pickEpisodeFromDir(dir, context);
}
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
const { args, scriptPath } = context;
checkPickerDependencies(args);
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
const dbPath = resolveImmersionDbPath();
if (!fs.existsSync(dbPath)) {
fail(`Watch history database not found: ${dbPath}`);
}
const rows = queryLocalWatchHistory(dbPath);
const series = groupHistoryBySeries(rows);
if (series.length === 0) {
fail('No local watch history found (or watched directories are not accessible).');
}
log('info', args.logLevel, `Watch history: ${series.length} series found in ${dbPath}`);
const coverPaths = args.useRofi
? materializeCoverArt(
dbPath,
series.map((seriesEntry) => seriesEntry.coverBlobHash),
)
: new Map<string, string>();
const seriesIcons = series.map((seriesEntry) =>
seriesEntry.coverBlobHash ? (coverPaths.get(seriesEntry.coverBlobHash) ?? null) : null,
);
const seriesIdx = pickIndex(
series.map(formatSeriesLabel),
'Watch History',
args.useRofi,
themePath,
seriesIcons,
);
if (seriesIdx < 0) return null;
const entry = series[seriesIdx]!;
const lastPath = path.resolve(entry.lastWatched.sourcePath);
const lastExists = fs.existsSync(lastPath);
const nextEpisode = findNextEpisode(lastPath);
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
if (lastExists) {
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
}
if (nextEpisode) {
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
}
actions.push({ kind: 'browse', label: 'Browse episodes' });
const entryIcon = seriesIcons[seriesIdx] ?? null;
const actionIdx = pickIndex(
actions.map((action) => action.label),
entry.displayName,
args.useRofi,
themePath,
actions.map(() => entryIcon),
);
if (actionIdx < 0) return null;
switch (actions[actionIdx]!.kind) {
case 'replay':
return lastPath;
case 'next':
return nextEpisode;
case 'browse':
return browseEpisodes(entry, context, themePath);
}
}
@@ -35,6 +35,7 @@ function createContext(): LauncherCommandContext {
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
+2
View File
@@ -198,6 +198,7 @@ export function createDefaultArgs(
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '',
@@ -231,6 +232,7 @@ export function applyRootOptionsToArgs(
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
if (options.rofi === true) parsed.useRofi = true;
if (options.history === true) parsed.history = true;
if (options.update === true) parsed.update = true;
if (options.version === true) parsed.version = true;
if (options.settings === true) parsed.settings = true;
+1
View File
@@ -64,6 +64,7 @@ function applyRootOptions(program: Command): void {
.option('--settings', 'Open settings window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-H, --history', 'Browse local watch history')
.option('-S, --start-overlay', 'Auto-start overlay')
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
}
+126
View File
@@ -0,0 +1,126 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { withReadonlyWalRetry } from './history-db.js';
const COVER_EXTENSIONS = ['.jpg', '.png', '.webp', '.gif'] as const;
const SAFE_COVER_HASH_PATTERN = /^[a-z0-9_-]+$/i;
export function getDefaultCoverCacheDir(): string {
return path.join(os.homedir(), '.cache', 'subminer', 'covers');
}
export function detectImageExtension(blob: Buffer): string {
if (blob.length >= 8 && blob.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
return '.png';
}
if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
return '.jpg';
}
if (
blob.length >= 12 &&
blob.subarray(0, 4).toString('ascii') === 'RIFF' &&
blob.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return '.webp';
}
if (blob.length >= 4 && blob.subarray(0, 3).toString('ascii') === 'GIF') {
return '.gif';
}
return '.jpg';
}
function findCachedCover(cacheDir: string, hash: string): string | null {
for (const ext of COVER_EXTENSIONS) {
const candidate = path.join(cacheDir, `${hash}${ext}`);
try {
if (fs.statSync(candidate).size > 0) return candidate;
} catch {
// not cached with this extension
}
}
return null;
}
function queryCoverBlobs(
dbPath: string,
hashes: string[],
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
): Map<string, Buffer> {
const blobs = new Map<string, Buffer>();
const db = new Database(dbPath, options);
try {
const hasBlobTable = db
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imm_cover_art_blobs'`)
.get();
if (!hasBlobTable) return blobs;
const stmt = db.query<{ cover_blob: Uint8Array | null }>(
'SELECT cover_blob FROM imm_cover_art_blobs WHERE blob_hash = ?',
);
for (const hash of hashes) {
const row = stmt.get(hash);
if (row?.cover_blob && row.cover_blob.length > 0) {
blobs.set(hash, Buffer.from(row.cover_blob));
}
}
return blobs;
} finally {
db.close();
}
}
function isSafeCoverHash(hash: string | null | undefined): hash is string {
return typeof hash === 'string' && SAFE_COVER_HASH_PATTERN.test(hash);
}
/**
* Ensures cover art blobs referenced by hash exist as image files in the cache
* directory, extracting missing ones from the stats database. Returns a map of
* blob hash to on-disk image path for every cover that could be materialized.
*/
export function materializeCoverArt(
dbPath: string,
hashes: Array<string | null | undefined>,
cacheDir: string = getDefaultCoverCacheDir(),
): Map<string, string> {
const wanted = Array.from(new Set(hashes.filter(isSafeCoverHash)));
const resolved = new Map<string, string>();
if (wanted.length === 0) return resolved;
const missing: string[] = [];
for (const hash of wanted) {
const cached = findCachedCover(cacheDir, hash);
if (cached) {
resolved.set(hash, cached);
} else {
missing.push(hash);
}
}
if (missing.length === 0) return resolved;
let blobs: Map<string, Buffer>;
try {
blobs = withReadonlyWalRetry(dbPath, (options) => queryCoverBlobs(dbPath, missing, options));
} catch {
return resolved;
}
if (blobs.size === 0) return resolved;
try {
fs.mkdirSync(cacheDir, { recursive: true });
} catch {
return resolved;
}
for (const [hash, blob] of blobs) {
const target = path.join(cacheDir, `${hash}${detectImageExtension(blob)}`);
try {
fs.writeFileSync(target, blob);
resolved.set(hash, target);
} catch {
// cache write failure just means no icon for this entry
}
}
return resolved;
}
+151
View File
@@ -0,0 +1,151 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { resolveConfigDir } from '../src/config/path-resolution.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import type { HistoryVideoRow } from './history-types.js';
import { resolvePathMaybe } from './util.js';
export function resolveImmersionDbPath(): string {
const root = readLauncherMainConfigObject();
const tracking =
root?.immersionTracking &&
typeof root.immersionTracking === 'object' &&
!Array.isArray(root.immersionTracking)
? (root.immersionTracking as Record<string, unknown>)
: null;
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
if (configured) return resolvePathMaybe(configured);
const configDir = resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
return path.join(configDir, 'immersion.sqlite');
}
interface RawHistoryRow {
video_id: number;
source_path: string | null;
parsed_title: string | null;
parsed_season: number | null;
parsed_episode: number | null;
anime_title: string | null;
last_watched_ms: number | bigint | null;
cover_blob_hash: string | null;
}
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
}
export function withReadonlyWalRetry<T>(
dbPath: string,
query: (options: { readonly?: boolean; readwrite?: boolean; create?: boolean }) => T,
): T {
try {
return query({ readonly: true });
} catch (error) {
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
return query({ readwrite: true, create: false });
}
}
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
if (!isWalModeSqliteDatabase(dbPath)) return false;
const code =
typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? '')
: '';
const message = error instanceof Error ? error.message : String(error);
const text = `${code} ${message}`.toLowerCase();
return (
text.includes('readonly') ||
text.includes('read-only') ||
text.includes('attempt to write a readonly database') ||
text.includes('sqlite_cantopen') ||
text.includes('unable to open database file')
);
}
function isWalModeSqliteDatabase(dbPath: string): boolean {
const header = Buffer.alloc(20);
let fd: number | null = null;
try {
fd = fs.openSync(dbPath, 'r');
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
} catch {
return false;
} finally {
if (fd !== null) fs.closeSync(fd);
}
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
}
function tableExists(db: Database, tableName: string): boolean {
return Boolean(
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
);
}
function readHistoryRows(
dbPath: string,
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
): HistoryVideoRow[] {
const db = new Database(dbPath, options);
try {
const hasMediaArt = tableExists(db, 'imm_media_art');
const coverSelect = hasMediaArt
? `COALESCE(
ma.cover_blob_hash,
(SELECT ma2.cover_blob_hash
FROM imm_media_art ma2
JOIN imm_videos v2 ON v2.video_id = ma2.video_id
WHERE v2.anime_id = v.anime_id AND ma2.cover_blob_hash IS NOT NULL
LIMIT 1)
) AS cover_blob_hash`
: 'NULL AS cover_blob_hash';
const coverJoin = hasMediaArt ? 'LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id' : '';
const rows = db
.query<RawHistoryRow>(
`
SELECT
v.video_id,
v.source_path,
v.parsed_title,
v.parsed_season,
v.parsed_episode,
COALESCE(a.title_romaji, a.canonical_title) AS anime_title,
MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms,
${coverSelect}
FROM imm_sessions s
JOIN imm_videos v ON v.video_id = s.video_id
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
${coverJoin}
WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != ''
GROUP BY v.video_id
ORDER BY last_watched_ms DESC
`,
)
.all();
return rows
.filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0)
.map((row) => ({
videoId: row.video_id,
sourcePath: row.source_path!,
parsedTitle: row.parsed_title,
parsedSeason: row.parsed_season,
parsedEpisode: row.parsed_episode,
animeTitle: row.anime_title,
lastWatchedMs: Number(row.last_watched_ms ?? 0),
coverBlobHash: row.cover_blob_hash,
}));
} finally {
db.close();
}
}
+132
View File
@@ -0,0 +1,132 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseMediaInfo } from '../src/jimaku/utils.js';
import { collectVideos } from './picker.js';
import type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i;
export function seasonNumberFromDirName(name: string): number | null {
const match = name.trim().match(SEASON_DIR_PATTERN);
if (!match) return null;
const parsed = Number.parseInt(match[1]!, 10);
return Number.isFinite(parsed) ? parsed : null;
}
export function resolveSeriesRoot(filePath: string): string {
const parent = path.dirname(filePath);
if (seasonNumberFromDirName(path.basename(parent)) !== null) {
return path.dirname(parent);
}
return parent;
}
export function groupHistoryBySeries(
rows: HistoryVideoRow[],
existsFn: (candidate: string) => boolean = fs.existsSync,
): HistorySeriesEntry[] {
const byRoot = new Map<string, HistorySeriesEntry>();
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
for (const row of sorted) {
const seriesRoot = resolveSeriesRoot(row.sourcePath);
const existing = byRoot.get(seriesRoot);
if (existing) {
if (existing.coverBlobHash === null && row.coverBlobHash !== null) {
existing.coverBlobHash = row.coverBlobHash;
}
continue;
}
if (!existsFn(seriesRoot)) continue;
const displayName =
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
byRoot.set(seriesRoot, {
seriesRoot,
displayName,
lastWatched: row,
coverBlobHash: row.coverBlobHash,
});
}
return Array.from(byRoot.values());
}
function compareNatural(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
export function sortVideosByEpisode(videos: string[]): string[] {
const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) }));
parsed.sort((a, b) => {
if (a.info.episode !== null && b.info.episode !== null) {
const seasonA = a.info.season ?? 0;
const seasonB = b.info.season ?? 0;
if (seasonA !== seasonB) return seasonA - seasonB;
if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode;
}
return compareNatural(a.video, b.video);
});
return parsed.map((entry) => entry.video);
}
function dirContainsVideo(dir: string): boolean {
return collectVideos(dir, true).length > 0;
}
export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(seriesRoot, { withFileTypes: true });
} catch {
return [];
}
const dirs = entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
path: path.join(seriesRoot, entry.name),
season: seasonNumberFromDirName(entry.name),
}))
.filter((entry) => dirContainsVideo(entry.path));
dirs.sort((a, b) => {
if (a.season !== null && b.season !== null && a.season !== b.season) {
return a.season - b.season;
}
return compareNatural(a.name, b.name);
});
return dirs;
}
function findFirstEpisodeInNextSeason(resolvedLast: string, dir: string): string | null {
const seriesRoot = resolveSeriesRoot(resolvedLast);
if (seriesRoot === dir) return null;
const seasons = listSeasonDirs(seriesRoot);
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null;
const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false));
return nextSeason[0] ?? null;
}
export function findNextEpisode(lastPath: string): string | null {
const resolvedLast = path.resolve(lastPath);
const dir = path.dirname(resolvedLast);
const episodes = sortVideosByEpisode(collectVideos(dir, false));
const idx = episodes.indexOf(resolvedLast);
if (idx >= 0) {
if (idx + 1 < episodes.length) return episodes[idx + 1]!;
} else {
const lastInfo = parseMediaInfo(resolvedLast);
if (lastInfo.episode !== null) {
const candidate = episodes.find((episode) => {
const info = parseMediaInfo(episode);
return info.episode !== null && info.episode > lastInfo.episode!;
});
if (candidate) return candidate;
}
}
return findFirstEpisodeInNextSeason(resolvedLast, dir);
}
+23
View File
@@ -0,0 +1,23 @@
export interface HistoryVideoRow {
videoId: number;
sourcePath: string;
parsedTitle: string | null;
parsedSeason: number | null;
parsedEpisode: number | null;
animeTitle: string | null;
lastWatchedMs: number;
coverBlobHash: string | null;
}
export interface HistorySeriesEntry {
seriesRoot: string;
displayName: string;
lastWatched: HistoryVideoRow;
coverBlobHash: string | null;
}
export interface SeasonDirEntry {
name: string;
path: string;
season: number | null;
}
+441
View File
@@ -0,0 +1,441 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import {
detectImageExtension,
findNextEpisode,
groupHistoryBySeries,
isReadonlyWalRetryError,
listSeasonDirs,
materializeCoverArt,
queryLocalWatchHistory,
resolveSeriesRoot,
seasonNumberFromDirName,
sortVideosByEpisode,
type HistoryVideoRow,
} from './history.js';
function makeRow(overrides: Partial<HistoryVideoRow> = {}): HistoryVideoRow {
return {
videoId: 1,
sourcePath: '/media/anime/Show/Season-1/Show - S01E01.mkv',
parsedTitle: 'Show',
parsedSeason: 1,
parsedEpisode: 1,
animeTitle: null,
lastWatchedMs: 1000,
coverBlobHash: null,
...overrides,
};
}
test('seasonNumberFromDirName detects common season directory names', () => {
assert.equal(seasonNumberFromDirName('Season-1'), 1);
assert.equal(seasonNumberFromDirName('Season 2'), 2);
assert.equal(seasonNumberFromDirName('S03'), 3);
assert.equal(seasonNumberFromDirName('season_04'), 4);
assert.equal(seasonNumberFromDirName('Specials'), null);
assert.equal(seasonNumberFromDirName('Show Name'), null);
});
test('resolveSeriesRoot skips season directories', () => {
assert.equal(
resolveSeriesRoot('/media/anime/Show/Season-1/Show - S01E01.mkv'),
'/media/anime/Show',
);
assert.equal(resolveSeriesRoot('/media/anime/Show/Show - 01.mkv'), '/media/anime/Show');
});
test('groupHistoryBySeries keeps most recent entry per series root', () => {
const rows = [
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000 }),
makeRow({
videoId: 2,
sourcePath: '/media/anime/Show/Season-1/Show - S01E02.mkv',
parsedEpisode: 2,
lastWatchedMs: 3000,
}),
makeRow({
videoId: 3,
sourcePath: '/media/anime/Other/Other - 05.mkv',
parsedTitle: 'Other',
parsedSeason: null,
parsedEpisode: 5,
lastWatchedMs: 2000,
}),
];
const series = groupHistoryBySeries(rows, () => true);
assert.equal(series.length, 2);
assert.equal(series[0]?.displayName, 'Show');
assert.equal(series[0]?.seriesRoot, '/media/anime/Show');
assert.equal(series[0]?.lastWatched.parsedEpisode, 2);
assert.equal(series[1]?.displayName, 'Other');
});
test('groupHistoryBySeries filters series roots that no longer exist', () => {
const rows = [
makeRow({ videoId: 1 }),
makeRow({
videoId: 2,
sourcePath: '/gone/anime/Missing/Season-1/Missing - S01E01.mkv',
parsedTitle: 'Missing',
lastWatchedMs: 5000,
}),
];
const series = groupHistoryBySeries(rows, (candidate) => !candidate.startsWith('/gone/'));
assert.equal(series.length, 1);
assert.equal(series[0]?.displayName, 'Show');
});
test('groupHistoryBySeries falls back to directory name for display', () => {
const rows = [
makeRow({
sourcePath: '/media/anime/Some Show Dir/video.mkv',
parsedTitle: null,
animeTitle: null,
}),
];
const series = groupHistoryBySeries(rows, () => true);
assert.equal(series[0]?.displayName, 'Some Show Dir');
});
test('sortVideosByEpisode orders by parsed episode with natural fallback', () => {
const videos = [
'/media/Show/Show - S01E10 - Ten.mkv',
'/media/Show/Show - S01E02 - Two.mkv',
'/media/Show/Show - S01E01 - One.mkv',
];
assert.deepEqual(sortVideosByEpisode(videos), [
'/media/Show/Show - S01E01 - One.mkv',
'/media/Show/Show - S01E02 - Two.mkv',
'/media/Show/Show - S01E10 - Ten.mkv',
]);
});
function createSeriesTree(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-'));
const seriesRoot = path.join(root, 'Show');
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
fs.mkdirSync(season1, { recursive: true });
fs.mkdirSync(season2, { recursive: true });
fs.mkdirSync(path.join(seriesRoot, 'extras-empty'), { recursive: true });
for (const name of ['Show - S01E01.mkv', 'Show - S01E02.mkv', 'Show - S01E03.mkv']) {
fs.writeFileSync(path.join(season1, name), '');
}
fs.writeFileSync(path.join(season2, 'Show - S02E01.mkv'), '');
fs.writeFileSync(path.join(season1, 'notes.txt'), '');
return seriesRoot;
}
test('listSeasonDirs returns only video-bearing directories in season order', () => {
const seriesRoot = createSeriesTree();
try {
const seasons = listSeasonDirs(seriesRoot);
assert.deepEqual(
seasons.map((entry) => entry.name),
['Season-1', 'Season-2'],
);
assert.deepEqual(
seasons.map((entry) => entry.season),
[1, 2],
);
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findNextEpisode advances within a season and across seasons', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
assert.equal(
findNextEpisode(path.join(season1, 'Show - S01E02.mkv')),
path.join(season1, 'Show - S01E03.mkv'),
);
assert.equal(
findNextEpisode(path.join(season1, 'Show - S01E03.mkv')),
path.join(season2, 'Show - S02E01.mkv'),
);
assert.equal(findNextEpisode(path.join(season2, 'Show - S02E01.mkv')), null);
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findNextEpisode falls back to episode numbers when file was removed', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv');
assert.equal(findNextEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findNextEpisode advances seasons when a deleted file was the last episode', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
const missing = path.join(season1, 'Show - S01E03 - Deleted Cut.mkv');
assert.equal(findNextEpisode(missing), path.join(season2, 'Show - S02E01.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
function createHistoryDb(
dbPath: string,
options: { wal?: boolean; coverArt?: boolean } = {},
): void {
const db = new Database(dbPath);
try {
if (options.wal) db.run('PRAGMA journal_mode = WAL;');
db.run(`
CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY,
canonical_title TEXT,
title_romaji TEXT
);
`);
db.run(`
CREATE TABLE imm_videos(
video_id INTEGER PRIMARY KEY,
anime_id INTEGER,
source_type INTEGER,
source_path TEXT,
parsed_title TEXT,
parsed_season INTEGER,
parsed_episode INTEGER
);
`);
db.run(`
CREATE TABLE imm_sessions(
session_id INTEGER PRIMARY KEY,
video_id INTEGER,
started_at_ms TEXT
);
`);
db.run(`INSERT INTO imm_anime VALUES (1, 'Show Season 1', 'Show Romaji');`);
db.run(`
INSERT INTO imm_videos VALUES
(1, 1, 1, '/media/Show/Season-1/Show - S01E01.mkv', 'Show', 1, 1),
(2, 1, 1, '/media/Show/Season-1/Show - S01E02.mkv', 'Show', 1, 2),
(3, NULL, 2, NULL, 'Remote Show', NULL, NULL),
(4, NULL, 1, '', 'Empty Path', NULL, NULL);
`);
db.run(`
INSERT INTO imm_sessions VALUES
(1, 1, '1000'),
(2, 1, '5000'),
(3, 2, '3000'),
(4, 3, '9000');
`);
if (options.coverArt) {
db.run(`
CREATE TABLE imm_media_art(
video_id INTEGER PRIMARY KEY,
cover_blob_hash TEXT
);
`);
db.run(`
CREATE TABLE imm_cover_art_blobs(
blob_hash TEXT PRIMARY KEY,
cover_blob BLOB NOT NULL
);
`);
// Art only on video 1; video 2 resolves it through the shared anime_id.
db.run(`INSERT INTO imm_media_art VALUES (1, 'hash-1');`);
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run('hash-1', PNG_MAGIC);
}
if (options.wal) db.run('PRAGMA wal_checkpoint(TRUNCATE);');
} finally {
db.close();
}
}
function assertHistoryRows(dbPath: string): void {
const rows = queryLocalWatchHistory(dbPath);
assert.equal(rows.length, 2);
assert.equal(rows[0]?.videoId, 1);
assert.equal(rows[0]?.lastWatchedMs, 5000);
assert.equal(rows[0]?.animeTitle, 'Show Romaji');
assert.equal(rows[1]?.videoId, 2);
assert.equal(rows[1]?.lastWatchedMs, 3000);
}
test('queryLocalWatchHistory returns local files ordered by most recent session', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-db-'));
const dbPath = path.join(dir, 'immersion.sqlite');
try {
createHistoryDb(dbPath);
assertHistoryRows(dbPath);
const rows = queryLocalWatchHistory(dbPath);
assert.equal(rows[0]?.coverBlobHash, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('queryLocalWatchHistory resolves cover hashes directly and via shared anime', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-art-'));
const dbPath = path.join(dir, 'immersion.sqlite');
try {
createHistoryDb(dbPath, { coverArt: true });
const rows = queryLocalWatchHistory(dbPath);
assert.equal(rows[0]?.videoId, 1);
assert.equal(rows[0]?.coverBlobHash, 'hash-1');
assert.equal(rows[1]?.videoId, 2);
assert.equal(rows[1]?.coverBlobHash, 'hash-1');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('materializeCoverArt extracts blobs to the cache dir and reuses cached files', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-covers-'));
const dbPath = path.join(dir, 'immersion.sqlite');
const cacheDir = path.join(dir, 'covers');
try {
createHistoryDb(dbPath, { coverArt: true });
const covers = materializeCoverArt(
dbPath,
['hash-1', 'hash-1', null, 'hash-missing'],
cacheDir,
);
const coverPath = covers.get('hash-1');
assert.ok(coverPath);
assert.equal(path.extname(coverPath!), '.png');
assert.ok(fs.statSync(coverPath!).size > 0);
assert.equal(covers.has('hash-missing'), false);
// Cached file is reused even when the database has disappeared.
fs.rmSync(dbPath);
const cachedCovers = materializeCoverArt(dbPath, ['hash-1'], cacheDir);
assert.equal(cachedCovers.get('hash-1'), coverPath);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('materializeCoverArt rejects cover hashes that escape the cache dir', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-cover-safety-'));
const dbPath = path.join(dir, 'immersion.sqlite');
const cacheDir = path.join(dir, 'covers');
const unsafeHash = '../escape';
try {
createHistoryDb(dbPath, { coverArt: true });
const db = new Database(dbPath);
try {
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run(unsafeHash, PNG_MAGIC);
} finally {
db.close();
}
const covers = materializeCoverArt(dbPath, [unsafeHash], cacheDir);
assert.equal(covers.has(unsafeHash), false);
assert.equal(fs.existsSync(path.join(dir, 'escape.png')), false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('detectImageExtension identifies common cover formats', () => {
assert.equal(detectImageExtension(PNG_MAGIC), '.png');
assert.equal(detectImageExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), '.jpg');
assert.equal(detectImageExtension(Buffer.from('RIFF0000WEBPVP8 ', 'ascii')), '.webp');
assert.equal(detectImageExtension(Buffer.from('GIF89a', 'ascii')), '.gif');
assert.equal(detectImageExtension(Buffer.from('unknown', 'ascii')), '.jpg');
});
test('groupHistoryBySeries backfills cover hash from older rows of the same series', () => {
const rows = [
makeRow({ videoId: 2, parsedEpisode: 2, lastWatchedMs: 3000, coverBlobHash: null }),
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000, coverBlobHash: 'hash-1' }),
];
const series = groupHistoryBySeries(rows, () => true);
assert.equal(series.length, 1);
assert.equal(series[0]?.lastWatched.videoId, 2);
assert.equal(series[0]?.coverBlobHash, 'hash-1');
});
test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-'));
const dbPath = path.join(dir, 'immersion.sqlite');
try {
createHistoryDb(dbPath, { wal: true });
// Reproduce the state after the app shuts down cleanly: WAL journal mode
// with no -wal/-shm sidecar files on disk. A read-only connection then
// fails at query time because it cannot recreate them.
fs.rmSync(`${dbPath}-wal`, { force: true });
fs.rmSync(`${dbPath}-shm`, { force: true });
assertHistoryRows(dbPath);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('isReadonlyWalRetryError only accepts readonly errors from WAL-mode databases', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-retry-'));
const walDbPath = path.join(dir, 'wal.sqlite');
const rollbackDbPath = path.join(dir, 'rollback.sqlite');
try {
createHistoryDb(walDbPath, { wal: true });
createHistoryDb(rollbackDbPath);
assert.equal(
isReadonlyWalRetryError(
Object.assign(new Error('attempt to write a readonly database'), {
code: 'SQLITE_READONLY',
}),
walDbPath,
),
true,
);
assert.equal(
isReadonlyWalRetryError(
Object.assign(new Error('unable to open database file'), {
code: 'SQLITE_CANTOPEN',
}),
walDbPath,
),
true,
);
assert.equal(
isReadonlyWalRetryError(new Error('no such table: imm_sessions'), walDbPath),
false,
);
assert.equal(
isReadonlyWalRetryError(
Object.assign(new Error('attempt to write a readonly database'), {
code: 'SQLITE_READONLY',
}),
rollbackDbPath,
),
false,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+4
View File
@@ -0,0 +1,4 @@
export * from './history-art.js';
export * from './history-db.js';
export * from './history-navigation.js';
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
+1
View File
@@ -29,6 +29,7 @@ function createArgs(): Args {
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
+11
View File
@@ -21,6 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runLogsCommand } from './commands/logs-command.js';
import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runHistoryCommand } from './commands/history-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
@@ -142,6 +143,16 @@ async function main(): Promise<void> {
return;
}
if (appContext.args.history) {
const selected = await runHistoryCommand(appContext);
if (!selected) {
log('info', args.logLevel, 'No watch history selection made, exiting');
return;
}
appContext.args.target = selected;
appContext.args.targetKind = 'file';
}
await runPlaybackCommand(appContext);
}
+1
View File
@@ -570,6 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
logLevel: 'error',
logRotation: 7,
passwordStore: '',
+13
View File
@@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => {
assert.equal(parsed.settings, true);
});
test('parseArgs maps root watch history flags', () => {
const shortParsed = parseArgs(['-H'], 'subminer', {});
const longParsed = parseArgs(['--history'], 'subminer', {});
const rofiParsed = parseArgs(['-R', '-H'], 'subminer', {});
const defaultParsed = parseArgs([], 'subminer', {});
assert.equal(shortParsed.history, true);
assert.equal(longParsed.history, true);
assert.equal(rofiParsed.history, true);
assert.equal(rofiParsed.useRofi, true);
assert.equal(defaultParsed.history, false);
});
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
const shortParsed = parseArgs(['-u'], 'subminer', {});
const longParsed = parseArgs(['--update'], 'subminer', {});
+1
View File
@@ -112,6 +112,7 @@ export interface Args {
texthookerOnly: boolean;
texthookerOpenBrowser: boolean;
useRofi: boolean;
history: boolean;
logLevel: LogLevel;
logRotation: LogRotation;
passwordStore: string;
+2 -2
View File
File diff suppressed because one or more lines are too long
-28
View File
@@ -1,28 +0,0 @@
## Highlights
### Fixed
- **YouTube Background Cache:** Fixed Windows background media cache startup for YouTube URLs opened directly in mpv.
- Resolved stream URLs are now tracked even when mpv still exposes the original YouTube playlist entry.
- Queued Anki media updates can append audio and images after the cache finishes instead of staying text-only.
- **YouTube Subtitle Picker Notifications:** Manual subtitle picker requests now show immediate status while SubMiner probes tracks and opens the modal.
- Subtitle download progress is replaced with a transient success notification after tracks load.
## What's Changed
- feat(youtube): notify on manual picker open and show success after track load by @ksyasuda in #133
- fix(youtube): recover source URL for background media cache on direct mpv open by @ksyasuda in #132
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+18 -4
View File
@@ -860,13 +860,18 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
assert.equal(storedMedia.length, 2);
});
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
const audioCalls: Array<{
path: string;
audioStreamIndex?: number;
normalizeAudio?: boolean;
}> = [];
const integration = new AnkiIntegration(
{
media: {
audioPadding: 0,
normalizeAudio: false,
},
},
{} as never,
@@ -896,13 +901,21 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
endTime: number,
audioPadding?: number,
audioStreamIndex?: number,
normalizeAudio?: boolean,
) => Promise<Buffer>;
};
generateAudio: () => Promise<Buffer | null>;
};
internals.mediaGenerator = {
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
audioCalls.push({ path: path.path, audioStreamIndex });
generateAudio: async (
path,
_startTime,
_endTime,
_audioPadding,
audioStreamIndex,
normalizeAudio,
) => {
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
return Buffer.from('audio');
},
};
@@ -913,6 +926,7 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
{
path: '/tmp/subminer-youtube-media-cache/media.mkv',
audioStreamIndex: undefined,
normalizeAudio: false,
},
]);
});
+5 -2
View File
@@ -348,6 +348,7 @@ export class AnkiIntegration {
endTime,
audioPadding,
audioStreamIndex,
this.config.media?.normalizeAudio !== false,
),
generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
@@ -502,6 +503,7 @@ export class AnkiIntegration {
endTime,
audioPadding,
audioStreamIndex,
this.config.media?.normalizeAudio !== false,
),
generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
@@ -701,8 +703,8 @@ export class AnkiIntegration {
});
}
isKnownWord(text: string): boolean {
return this.knownWordCache.isKnownWord(text);
isKnownWord(text: string, reading?: string): boolean {
return this.knownWordCache.isKnownWord(text, reading);
}
getKnownWordMatchMode(): NPlusOneMatchMode {
@@ -996,6 +998,7 @@ export class AnkiIntegration {
endTime,
this.config.media?.audioPadding,
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
this.config.media?.normalizeAudio !== false,
);
}
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import { toMpvEdlValue } from './mpv-edl-test-utils';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki';
@@ -269,9 +270,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
'!global_tags,title=test',
].join(';');
@@ -354,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, [videoUrl]);
assert.equal(storedMedia.length, 2);
assert.equal(updatedFields.length, 1);
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
+7 -4
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import { toMpvEdlValue } from './mpv-edl-test-utils';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki';
@@ -290,9 +291,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
'!global_tags,title=test',
].join(';');
@@ -397,8 +400,8 @@ test('CardCreationService uses stream-open-filename for remote media generation'
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, [videoUrl]);
});
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
+2
View File
@@ -65,6 +65,7 @@ interface CardCreationMediaGenerator {
endTime: number,
audioPadding?: number,
audioStreamIndex?: number,
normalizeAudio?: boolean,
): Promise<Buffer | null>;
generateScreenshot(
path: MediaInput,
@@ -842,6 +843,7 @@ export class CardCreationService {
videoPath,
mpvClient.currentAudioStreamIndex ?? undefined,
),
this.deps.getConfig().media?.normalizeAudio !== false,
);
}
@@ -26,6 +26,7 @@ function createWorkflowHarness() {
const deleted: number[][] = [];
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
const statuses: string[] = [];
const osdMessages: string[] = [];
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
const mergeCalls: Array<{
keepNoteId: number;
@@ -112,7 +113,9 @@ function createWorkflowHarness() {
statuses.push(message);
},
showNotification: async () => undefined,
showOsdNotification: () => undefined,
showOsdNotification: (message: string) => {
osdMessages.push(message);
},
logError: () => undefined,
logInfo: () => undefined,
truncateSentence: (value: string) => value,
@@ -125,6 +128,7 @@ function createWorkflowHarness() {
addedTags,
rememberedMerges,
statuses,
osdMessages,
mergeCalls,
setManualChoice: (choice: typeof manualChoice) => {
manualChoice = choice;
@@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
assert.equal(harness.updates.length, 0);
});
test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
const handled = await harness.workflow.handleManual(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(handled, false);
assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']);
assert.equal(harness.updates.length, 0);
});
test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: true,
cancelled: false,
});
harness.deps.client.notesInfo = async () => [];
const handled = await harness.workflow.handleManual(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(handled, false);
assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']);
});
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
@@ -98,6 +98,8 @@ export class FieldGroupingWorkflow {
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
if (!originalNotesInfo || originalNotesInfo.length === 0) {
// handleManual owns all user-facing notifications; callers must not re-notify on false.
this.deps.showOsdNotification('Field grouping failed: original card not found');
return false;
}
+4 -2
View File
@@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
assert.deepEqual(harness.manualCalls, []);
});
test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => {
test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => {
const harness = createHarness({
kikuFieldGrouping: 'manual',
noteIds: [9],
@@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man
expression: 'word-9',
},
]);
assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled');
// The manual workflow already notifies about its outcome (cancelled/unavailable/failed);
// the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts.
assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0);
});
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
+3 -4
View File
@@ -156,15 +156,14 @@ export class FieldGroupingService {
);
return;
}
const handled = await this.deps.handleFieldGroupingManual(
// The manual workflow owns all user-facing notifications for its outcomes (cancelled,
// unavailable, failed) — re-notifying on a false return here duplicated them.
await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
if (!handled) {
this.deps.showOsdNotification('Field grouping cancelled');
}
});
} catch (error) {
log.error('Error triggering field grouping:', (error as Error).message);
+251 -7
View File
@@ -108,6 +108,55 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0);
// v2 states carry no readings, so they load usable but stale to trigger a
// prompt upgrade refresh.
assert.equal(
(
manager as unknown as {
getMsUntilNextRefresh: () => number;
}
).getMsUntilNextRefresh(),
0,
);
} finally {
Date.now = originalDateNow;
manager.stopLifecycle();
cleanup();
}
});
test('KnownWordCacheManager startLifecycle keeps fresh v3 persisted cache without immediate refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
refreshMinutes: 60,
},
};
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
const originalDateNow = Date.now;
try {
Date.now = () => 120_000;
fs.writeFileSync(
statePath,
JSON.stringify({
version: 3,
refreshedAtMs: 120_000,
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
notes: {
'1': [{ word: '猫', reading: 'ねこ' }],
},
}),
'utf-8',
);
manager.startLifecycle();
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('猫', 'ねこ'), true);
assert.equal(manager.isKnownWord('猫', 'びょう'), false);
assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0);
assert.equal(
(
manager as unknown as {
@@ -263,13 +312,11 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
words: string[];
notes?: Record<string, string[]>;
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
};
assert.equal(persisted.version, 2);
assert.deepEqual(persisted.words.sort(), ['鳥']);
assert.equal(persisted.version, 3);
assert.deepEqual(persisted.notes, {
'1': ['鳥'],
'1': [{ word: '鳥', reading: null }],
});
} finally {
cleanup();
@@ -392,10 +439,10 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
scope: string;
words: string[];
notes: Record<string, Array<{ word: string; reading: string | null }>>;
};
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
assert.deepEqual(persisted.words, ['猫']);
assert.deepEqual(persisted.notes, { '1': [{ word: '猫', reading: null }] });
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
@@ -648,3 +695,200 @@ test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately
cleanup();
}
});
test('KnownWordCacheManager disambiguates known words by note reading', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '床' },
'Word Reading': { value: 'ゆか' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('床'), true);
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
assert.equal(manager.isKnownWord('床', 'ユカ'), true);
// Same spelling, different word (床/とこ "bed") must not match.
assert.equal(manager.isKnownWord('床', 'とこ'), false);
// Note readings stay matchable as kana words.
assert.equal(manager.isKnownWord('ゆか'), true);
assert.equal(manager.isKnownWord('とこ'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager probes reading fields even with per-deck word fields configured', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Expression',
},
knownWords: {
highlightEnabled: true,
decks: {
'Kaishi 1.5k': ['Word'],
},
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesByQuery.set('deck:"Kaishi 1.5k"', [1]);
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '床' },
'Word Reading': { value: 'ゆか' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
assert.equal(manager.isKnownWord('床', 'とこ'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager matches words without readings in any reading', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '床' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('床'), true);
assert.equal(manager.isKnownWord('床', 'とこ'), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager extracts word and reading from furigana word fields', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: 'お 決[き]まり' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('お決まり'), true);
assert.equal(manager.isKnownWord('お決まり', 'おきまり'), true);
assert.equal(manager.isKnownWord('お決まり', 'おさだまり'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager treats non-kana reading fields as words', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Reading: { value: '漢字' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('漢字'), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager keeps kana-only reading notes matchable', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Reading: { value: 'たべる' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('たべる'), true);
assert.equal(manager.isKnownWord('タベル'), true);
} finally {
cleanup();
}
});
+206 -101
View File
@@ -5,6 +5,16 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getConfiguredWordFieldName } from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki';
import { createLogger } from '../logger';
import {
DEFAULT_KNOWN_WORD_READING_FIELDS,
KnownWordEntry,
convertKatakanaToHiragana,
isReadingFieldName,
knownWordEntryListsEqual,
normalizeKnownReadingForLookup,
normalizeKnownWordEntryList,
parseFuriganaAnnotatedText,
} from './known-word-entries';
const log = createLogger('anki').child('integration.known-word-cache');
@@ -79,7 +89,16 @@ interface KnownWordCacheStateV2 {
readonly notes: Record<string, string[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
interface KnownWordCacheStateV3 {
readonly version: 3;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
const NO_READING_KEY = '';
interface KnownWordCacheClient {
findNotes: (
@@ -106,9 +125,12 @@ type KnownWordQueryScope = {
export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0;
private knownWordsStateKey = '';
private knownWords: Set<string> = new Set();
private wordReferenceCounts = new Map<string, number>();
private noteWordsById = new Map<number, string[]>();
// word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
// entries fail open: the word matches regardless of the token's reading.
private wordReadingCounts = new Map<string, Map<string, number>>();
// hiragana reading → note count, so kana tokens still match by reading alone.
private readingCounts = new Map<string, number>();
private noteEntriesById = new Map<number, KnownWordEntry[]>();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
private isRefreshingKnownWords = false;
@@ -120,13 +142,28 @@ export class KnownWordCacheManager {
);
}
isKnownWord(text: string): boolean {
isKnownWord(text: string, reading?: string): boolean {
if (!this.isKnownWordCacheEnabled()) {
return false;
}
const normalized = this.normalizeKnownWordForLookup(text);
return normalized.length > 0 ? this.knownWords.has(normalized) : false;
if (normalized.length === 0) {
return false;
}
const knownReadings = this.wordReadingCounts.get(normalized);
if (knownReadings && knownReadings.size > 0) {
const normalizedReading =
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
return (
normalizedReading.length === 0 ||
knownReadings.has(NO_READING_KEY) ||
knownReadings.has(normalizedReading)
);
}
return this.readingCounts.has(convertKatakanaToHiragana(normalized));
}
refresh(force = false): Promise<void> {
@@ -173,7 +210,7 @@ export class KnownWordCacheManager {
let didMutateCache = false;
const currentStateKey = this.getKnownWordCacheStateKey();
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
didMutateCache = this.knownWords.size > 0 || this.noteWordsById.size > 0;
didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
this.clearKnownWordCacheState();
}
if (!this.knownWordsStateKey) {
@@ -185,8 +222,8 @@ export class KnownWordCacheManager {
return didMutateCache;
}
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
const nextEntries = this.extractKnownWordEntriesFromNoteInfo(noteInfo, preferredFields);
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextEntries);
if (!changed) {
return didMutateCache;
}
@@ -198,7 +235,7 @@ export class KnownWordCacheManager {
log.info(
'Known-word cache updated in-session',
`noteId=${noteInfo.noteId}`,
`wordCount=${nextWords.length}`,
`wordCount=${nextEntries.length}`,
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
);
return true;
@@ -236,11 +273,11 @@ export class KnownWordCacheManager {
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
if (this.noteWordsById.size === 0) {
if (this.noteEntriesById.size === 0) {
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
} else {
const currentNoteIdSet = new Set(currentNoteIds);
for (const noteId of Array.from(this.noteWordsById.keys())) {
for (const noteId of Array.from(this.noteEntriesById.keys())) {
if (!currentNoteIdSet.has(noteId)) {
this.removeNoteSnapshot(noteId);
}
@@ -251,7 +288,7 @@ export class KnownWordCacheManager {
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(
noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo(
this.extractKnownWordEntriesFromNoteInfo(
noteInfo,
noteFieldsById.get(noteInfo.noteId),
),
@@ -266,7 +303,7 @@ export class KnownWordCacheManager {
log.info(
'Known-word cache refreshed',
`noteCount=${currentNoteIds.length}`,
`wordCount=${this.knownWords.size}`,
`wordCount=${this.wordReadingCounts.size}`,
);
} catch (error) {
log.warn('Failed to refresh known-word cache:', (error as Error).message);
@@ -291,7 +328,13 @@ export class KnownWordCacheManager {
private getDefaultKnownWordFields(): string[] {
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
return this.withDefaultReadingFields([configuredWordField, 'Word']);
}
// Reading fields are always probed (even when a deck configures explicit
// word fields) so entries can carry the reading their note teaches.
private withDefaultReadingFields(fields: string[]): string[] {
return [...new Set([...fields, ...DEFAULT_KNOWN_WORD_READING_FIELDS])];
}
private getKnownWordDecks(): string[] {
@@ -337,7 +380,9 @@ export class KnownWordCacheManager {
.filter((field) => field.length > 0),
),
];
return normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields();
return normalizedFields.length > 0
? this.withDefaultReadingFields(normalizedFields)
: this.getDefaultKnownWordFields();
}
const deckFields = selectedDeckEntry[1];
@@ -351,7 +396,7 @@ export class KnownWordCacheManager {
),
];
if (normalizedFields.length > 0) {
return normalizedFields;
return this.withDefaultReadingFields(normalizedFields);
}
}
@@ -382,7 +427,10 @@ export class KnownWordCacheManager {
: [];
scopes.push({
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
fields: normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(),
fields:
normalizedFields.length > 0
? this.withDefaultReadingFields(normalizedFields)
: this.getDefaultKnownWordFields(),
});
}
if (scopes.length > 0) {
@@ -490,7 +538,7 @@ export class KnownWordCacheManager {
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(
noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
this.extractKnownWordEntriesFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
);
}
}
@@ -519,56 +567,77 @@ export class KnownWordCacheManager {
return noteInfos;
}
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
const normalizedWords = normalizeKnownWordList(nextWords);
const previousWords = this.noteWordsById.get(noteId) ?? [];
if (knownWordListsEqual(previousWords, normalizedWords)) {
private replaceNoteSnapshot(noteId: number, nextEntries: KnownWordEntry[]): boolean {
const normalizedEntries = normalizeKnownWordEntryList(nextEntries);
const previousEntries = this.noteEntriesById.get(noteId) ?? [];
if (knownWordEntryListsEqual(previousEntries, normalizedEntries)) {
return false;
}
this.removeWordsFromCounts(previousWords);
if (normalizedWords.length > 0) {
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
this.removeEntriesFromCounts(previousEntries);
if (normalizedEntries.length > 0) {
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
} else {
this.noteWordsById.delete(noteId);
this.noteEntriesById.delete(noteId);
}
return true;
}
private removeNoteSnapshot(noteId: number): void {
const previousWords = this.noteWordsById.get(noteId);
if (!previousWords) {
const previousEntries = this.noteEntriesById.get(noteId);
if (!previousEntries) {
return;
}
this.noteWordsById.delete(noteId);
this.removeWordsFromCounts(previousWords);
this.noteEntriesById.delete(noteId);
this.removeEntriesFromCounts(previousEntries);
}
private addWordsToCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
this.wordReferenceCounts.set(word, nextCount);
this.knownWords.add(word);
private addEntriesToCounts(entries: KnownWordEntry[]): void {
for (const entry of entries) {
const readingKey = entry.reading ?? NO_READING_KEY;
let readings = this.wordReadingCounts.get(entry.word);
if (!readings) {
readings = new Map();
this.wordReadingCounts.set(entry.word, readings);
}
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
if (entry.reading) {
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
}
}
}
private removeWordsFromCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
if (nextCount > 0) {
this.wordReferenceCounts.set(word, nextCount);
} else {
this.wordReferenceCounts.delete(word);
this.knownWords.delete(word);
private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
for (const entry of entries) {
const readingKey = entry.reading ?? NO_READING_KEY;
const readings = this.wordReadingCounts.get(entry.word);
if (readings) {
const nextCount = (readings.get(readingKey) ?? 0) - 1;
if (nextCount > 0) {
readings.set(readingKey, nextCount);
} else {
readings.delete(readingKey);
if (readings.size === 0) {
this.wordReadingCounts.delete(entry.word);
}
}
}
if (entry.reading) {
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
if (nextReadingCount > 0) {
this.readingCounts.set(entry.reading, nextReadingCount);
} else {
this.readingCounts.delete(entry.reading);
}
}
}
}
private clearInMemoryState(): void {
this.knownWords = new Set();
this.wordReferenceCounts = new Map();
this.noteWordsById = new Map();
this.wordReadingCounts = new Map();
this.readingCounts = new Map();
this.noteEntriesById = new Map();
this.knownWordsLastRefreshedAtMs = 0;
}
@@ -601,32 +670,48 @@ export class KnownWordCacheManager {
}
this.clearInMemoryState();
if (parsed.version === 3) {
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedEntries = normalizeKnownWordEntryList(entries);
if (normalizedEntries.length === 0) {
continue;
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
}
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope;
return;
}
if (parsed.version === 2) {
// Older states have no readings; load them reading-less (fail-open,
// matching the old behavior) but leave the cache marked stale so the
// next refresh upgrades entries with readings from Anki.
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedWords = normalizeKnownWordList(words);
if (normalizedWords.length === 0) {
const normalizedEntries = normalizeKnownWordEntryList(
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
);
if (normalizedEntries.length === 0) {
continue;
}
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
}
} else {
for (const value of parsed.words) {
const normalized = this.normalizeKnownWordForLookup(value);
if (!normalized) {
continue;
}
this.knownWords.add(normalized);
this.wordReferenceCounts.set(normalized, 1);
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
}
this.knownWordsStateKey = parsed.scope;
return;
}
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope;
// v1 has no per-note snapshots to convert; refetch from Anki.
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
} catch (error) {
log.warn('Failed to load known-word cache state:', (error as Error).message);
this.clearInMemoryState();
@@ -636,18 +721,17 @@ export class KnownWordCacheManager {
private persistKnownWordCacheState(): void {
try {
const notes: Record<string, string[]> = {};
for (const [noteId, words] of this.noteWordsById.entries()) {
if (words.length > 0) {
notes[String(noteId)] = words;
const notes: Record<string, KnownWordEntry[]> = {};
for (const [noteId, entries] of this.noteEntriesById.entries()) {
if (entries.length > 0) {
notes[String(noteId)] = entries;
}
}
const state: KnownWordCacheStateV2 = {
version: 2,
const state: KnownWordCacheStateV3 = {
version: 3,
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsStateKey,
words: Array.from(this.knownWords),
notes,
};
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
@@ -659,14 +743,18 @@ export class KnownWordCacheManager {
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
if (candidate.version !== 1 && candidate.version !== 2) return false;
if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false;
if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
return false;
}
if (candidate.version === 2) {
if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false;
if (candidate.version !== 3) {
if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
return false;
}
}
if (candidate.version === 2 || candidate.version === 3) {
if (
typeof candidate.notes !== 'object' ||
candidate.notes === null ||
@@ -674,10 +762,18 @@ export class KnownWordCacheManager {
) {
return false;
}
const isValidNoteEntry =
candidate.version === 2
? (entry: unknown): boolean => typeof entry === 'string'
: (entry: unknown): boolean =>
typeof entry === 'object' &&
entry !== null &&
typeof (entry as KnownWordEntry).word === 'string' &&
((entry as KnownWordEntry).reading === null ||
typeof (entry as KnownWordEntry).reading === 'string');
if (
!Object.values(candidate.notes as Record<string, unknown>).every(
(entry) =>
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
)
) {
return false;
@@ -686,11 +782,12 @@ export class KnownWordCacheManager {
return true;
}
private extractNormalizedKnownWordsFromNoteInfo(
private extractKnownWordEntriesFromNoteInfo(
noteInfo: KnownWordCacheNoteInfo,
preferredFields = this.getConfiguredFields(),
): string[] {
const words: string[] = [];
): KnownWordEntry[] {
const wordValues: string[] = [];
let noteReading: string | null = null;
for (const preferredField of preferredFields) {
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
if (!fieldName) continue;
@@ -698,12 +795,36 @@ export class KnownWordCacheManager {
const raw = noteInfo.fields[fieldName]?.value;
if (!raw) continue;
const normalized = this.normalizeKnownWordForLookup(raw);
if (normalized) {
words.push(normalized);
const cleaned = this.normalizeRawKnownWordValue(raw);
if (!cleaned) continue;
if (isReadingFieldName(preferredField)) {
const normalizedReading = normalizeKnownReadingForLookup(cleaned);
if (normalizedReading) {
noteReading ??= normalizedReading;
continue;
}
// Non-kana content in a reading field: treat it as a word so decks
// with repurposed reading fields keep matching (fail-open).
}
wordValues.push(cleaned);
}
return normalizeKnownWordList(words);
const entries: KnownWordEntry[] = [];
for (const value of wordValues) {
const parsed = parseFuriganaAnnotatedText(value);
const word = parsed.text.trim().toLowerCase();
if (!word) continue;
const inlineReading = parsed.reading ? normalizeKnownReadingForLookup(parsed.reading) : '';
entries.push({ word, reading: inlineReading || noteReading });
}
// Kana-only notes (reading field but no word field) stay matchable.
if (entries.length === 0 && noteReading) {
entries.push({ word: noteReading, reading: noteReading });
}
return normalizeKnownWordEntryList(entries);
}
private normalizeRawKnownWordValue(value: string): string {
@@ -718,22 +839,6 @@ export class KnownWordCacheManager {
}
}
function normalizeKnownWordList(words: string[]): string[] {
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
}
function knownWordListsEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
const exact = availableFieldNames.find((name) => name === preferredName);
if (exact) return exact;
+113
View File
@@ -0,0 +1,113 @@
// Known-word cache entries pair a word with the reading its Anki note teaches,
// so spelling collisions across readings (e.g. 床/ゆか vs 床/とこ) don't mark
// unrelated words as known. reading === null means the note carries no usable
// reading and the word matches in any reading (fail-open).
export interface KnownWordEntry {
word: string;
reading: string | null;
}
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
const KATAKANA_CODEPOINT_START = 0x30a1;
const KATAKANA_CODEPOINT_END = 0x30f6;
const FURIGANA_SEGMENT_PATTERN = /([^\s \[\]]*)\[([^\]]*)\]/g;
const FURIGANA_BRACKET_PATTERN = /\[[^\]]*\]/g;
const WHITESPACE_PATTERN = /[\s ]+/g;
// Reading-bearing field names probed on every known-word note, in addition to
// any configured word fields (covers Kaishi's "Word Reading" and Lapis's
// "ExpressionReading" note types).
export const DEFAULT_KNOWN_WORD_READING_FIELDS = [
'Reading',
'Word Reading',
'ExpressionReading',
'Expression Reading',
];
export function isReadingFieldName(fieldName: string): boolean {
return /reading/i.test(fieldName);
}
export function convertKatakanaToHiragana(text: string): string {
let converted = '';
for (const char of text) {
const code = char.codePointAt(0);
if (code !== undefined && code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) {
converted += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET);
continue;
}
converted += char;
}
return converted;
}
function isHiraganaReadingChar(char: string): boolean {
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
return (code >= 0x3041 && code <= 0x309f) || code === 0x30fc;
}
// Splits Anki furigana syntax (`床[とこ]`, `お 決[き]まり`) into base text and
// reading. Values without brackets pass through with reading null.
export function parseFuriganaAnnotatedText(value: string): {
text: string;
reading: string | null;
} {
if (!value.includes('[')) {
return { text: value, reading: null };
}
const text = value.replace(FURIGANA_BRACKET_PATTERN, '').replace(WHITESPACE_PATTERN, '');
const reading = value.replace(FURIGANA_SEGMENT_PATTERN, '$2').replace(WHITESPACE_PATTERN, '');
return { text, reading: reading.length > 0 ? reading : null };
}
// Returns the hiragana-normalized reading, or '' when the value is not a
// plausible kana reading (callers fall back to text-only matching then).
export function normalizeKnownReadingForLookup(value: string): string {
const parsed = parseFuriganaAnnotatedText(value.trim());
const candidate = (parsed.reading ?? parsed.text).trim();
if (!candidate) {
return '';
}
const hiragana = convertKatakanaToHiragana(candidate);
for (const char of hiragana) {
if (!isHiraganaReadingChar(char)) {
return '';
}
}
return hiragana;
}
export function makeKnownWordEntryKey(entry: KnownWordEntry): string {
return `${entry.word}\u0000${entry.reading ?? ''}`;
}
export function normalizeKnownWordEntryList(entries: KnownWordEntry[]): KnownWordEntry[] {
const byKey = new Map<string, KnownWordEntry>();
for (const entry of entries) {
const word = entry.word.trim();
if (!word) {
continue;
}
const reading = entry.reading?.trim() || null;
const normalized: KnownWordEntry = { word, reading };
byKey.set(makeKnownWordEntryKey(normalized), normalized);
}
return [...byKey.values()].sort((left, right) =>
makeKnownWordEntryKey(left).localeCompare(makeKnownWordEntryKey(right)),
);
}
export function knownWordEntryListsEqual(left: KnownWordEntry[], right: KnownWordEntry[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (makeKnownWordEntryKey(left[index]!) !== makeKnownWordEntryKey(right[index]!)) {
return false;
}
}
return true;
}
+56 -7
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as mediaSource from './media-source';
import { toMpvEdlValue } from './mpv-edl-test-utils';
const { resolveMediaGenerationInputPath } = mediaSource;
@@ -53,9 +54,11 @@ test('resolveMediaGenerationInputPath prefers stream-open-filename for remote me
});
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
'!global_tags,title=test',
].join(';');
@@ -74,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video
'video',
);
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
assert.equal(audioResult, audioUrl);
assert.equal(videoResult, videoUrl);
});
test('resolveMediaGenerationInputPath strips mpv edl segment options from unwrapped streams', async () => {
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const signedVideoUrl =
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
const edlSource = [
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(signedVideoUrl)},title=clip,length=73,timestamps=chapters`,
'!global_tags,title=test',
].join(';');
const result = await resolveMediaGenerationInputPath(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => edlSource,
},
'video',
);
assert.equal(result, signedVideoUrl);
});
test('resolveMediaGenerationInputPath ignores length-guarded URLs in mpv edl headers', async () => {
const initUrl = 'https://init.example/init.mp4';
const audioUrl = 'https://audio.example/stream';
const videoUrl = 'https://video.example/stream';
const edlSource = [
`edl://!mp4_dash,init=${toMpvEdlValue(initUrl)}`,
'!new_stream',
toMpvEdlValue(audioUrl),
'!new_stream',
toMpvEdlValue(videoUrl),
].join(';');
const audioResult = await resolveMediaGenerationInputPath(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => edlSource,
},
'audio',
);
assert.equal(audioResult, audioUrl);
});
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
@@ -97,9 +144,11 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function');
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
].join(';');
const result = await resolver!(
@@ -117,7 +166,7 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
'audio',
);
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
assert.equal(result?.path, audioUrl);
assert.equal(result?.singleResolvedStream, true);
assert.equal(result?.inputOptions?.reconnect, true);
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
+3 -3
View File
@@ -1,6 +1,7 @@
import { isRemoteMediaPath } from '../jimaku/utils';
import type { MediaInput, MediaInputOptions } from '../media-input';
import type { MpvClient } from '../types/runtime';
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
export type MediaGenerationKind = 'audio' | 'video';
export type MediaGenerationInputSource =
@@ -73,9 +74,8 @@ function normalizeHeaderName(value: string): string | null {
}
function extractUrlsFromMpvEdlSource(source: string): string[] {
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
return [...matches]
.map((match) => trimToNonEmptyString(match[1]))
return extractFileUrlsFromMpvEdlSource(source)
.map((value) => trimToNonEmptyString(value))
.filter((value): value is string => value !== null);
}
@@ -0,0 +1,3 @@
export function toMpvEdlValue(value: string): string {
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
import { toMpvEdlValue } from './mpv-edl-test-utils';
test('extractFileUrlsFromMpvEdlSource honors length-guarded file values', () => {
const url =
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
const source = `edl://!new_stream;${toMpvEdlValue(url)},title=clip,length=73`;
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [url]);
});
test('extractFileUrlsFromMpvEdlSource reads file parameters', () => {
const initUrl = 'https://init.example/init.mp4';
const fileUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const source = `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)};file=${toMpvEdlValue(
fileUrl,
)},length=42`;
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [fileUrl]);
});
test('extractFileUrlsFromMpvEdlSource aggregates file URLs across entries', () => {
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const source = [
'edl://!new_stream',
toMpvEdlValue(audioUrl),
'!new_stream',
`file=${toMpvEdlValue(videoUrl)},length=50`,
'!global_tags,title=test',
].join(';');
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [audioUrl, videoUrl]);
});
+195
View File
@@ -0,0 +1,195 @@
const EDL_URI_PREFIX = 'edl://';
const BYTE_COMMA = ','.charCodeAt(0);
const BYTE_CR = '\r'.charCodeAt(0);
const BYTE_EQUALS = '='.charCodeAt(0);
const BYTE_EXCLAMATION = '!'.charCodeAt(0);
const BYTE_LF = '\n'.charCodeAt(0);
const BYTE_PERCENT = '%'.charCodeAt(0);
const BYTE_SEMICOLON = ';'.charCodeAt(0);
function isDigitByte(value: number | undefined): value is number {
return value !== undefined && value >= 48 && value <= 57;
}
function isEntrySeparator(value: number | undefined): boolean {
return value === BYTE_SEMICOLON || value === BYTE_LF || value === BYTE_CR;
}
function isParamSeparator(value: number | undefined): boolean {
return value === BYTE_COMMA || isEntrySeparator(value);
}
function decodeBytes(buffer: Buffer, start: number, end: number): string {
return buffer.subarray(start, end).toString('utf8');
}
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
function toEdlDataBuffer(source: string): Buffer {
const data = source.startsWith(EDL_URI_PREFIX) ? source.slice(EDL_URI_PREFIX.length) : source;
return Buffer.from(data, 'utf8');
}
function parseLengthGuardedValue(
buffer: Buffer,
position: number,
): { value: string; end: number } | null {
if (buffer[position] !== BYTE_PERCENT) {
return null;
}
let cursor = position + 1;
if (!isDigitByte(buffer[cursor])) {
return null;
}
let byteLength = 0;
while (true) {
const digit = buffer[cursor];
if (!isDigitByte(digit)) {
break;
}
byteLength = byteLength * 10 + (digit - 48);
cursor += 1;
}
if (buffer[cursor] !== BYTE_PERCENT) {
return null;
}
const valueStart = cursor + 1;
const valueEnd = valueStart + byteLength;
if (valueEnd > buffer.length) {
return null;
}
return {
value: decodeBytes(buffer, valueStart, valueEnd),
end: valueEnd,
};
}
function skipEntrySeparators(buffer: Buffer, position: number): number {
let cursor = position;
while (cursor < buffer.length && isEntrySeparator(buffer[cursor])) {
cursor += 1;
}
return cursor;
}
function skipEntry(buffer: Buffer, position: number): number {
let cursor = position;
while (cursor < buffer.length) {
const guardedValue = parseLengthGuardedValue(buffer, cursor);
if (guardedValue) {
cursor = guardedValue.end;
continue;
}
if (isEntrySeparator(buffer[cursor])) {
break;
}
cursor += 1;
}
return cursor;
}
function parseRawValue(buffer: Buffer, position: number): { value: string; end: number } {
let cursor = position;
while (
cursor < buffer.length &&
!isParamSeparator(buffer[cursor]) &&
buffer[cursor] !== BYTE_EXCLAMATION
) {
cursor += 1;
}
return {
value: decodeBytes(buffer, position, cursor),
end: cursor,
};
}
function parseParamValue(buffer: Buffer, position: number): { value: string; end: number } {
return parseLengthGuardedValue(buffer, position) ?? parseRawValue(buffer, position);
}
function parseOptionalParamName(
buffer: Buffer,
position: number,
): { name: string | null; valueStart: number } {
let cursor = position;
while (
cursor < buffer.length &&
!isParamSeparator(buffer[cursor]) &&
buffer[cursor] !== BYTE_PERCENT &&
buffer[cursor] !== BYTE_EXCLAMATION
) {
if (buffer[cursor] === BYTE_EQUALS) {
return {
name: decodeBytes(buffer, position, cursor),
valueStart: cursor + 1,
};
}
cursor += 1;
}
return { name: null, valueStart: position };
}
function parseSegmentEntry(buffer: Buffer, position: number): { urls: string[]; end: number } {
const urls: string[] = [];
let cursor = position;
let unnamedParamIndex = 0;
while (cursor < buffer.length && !isEntrySeparator(buffer[cursor])) {
const { name, valueStart } = parseOptionalParamName(buffer, cursor);
const value = parseParamValue(buffer, valueStart);
const lowerName = name?.toLowerCase() ?? null;
const isFileParam = lowerName === 'file' || (lowerName === null && unnamedParamIndex === 0);
if (isFileParam && isHttpUrl(value.value)) {
urls.push(value.value);
}
if (lowerName === null) {
unnamedParamIndex += 1;
}
cursor = value.end;
if (buffer[cursor] === BYTE_COMMA) {
cursor += 1;
continue;
}
if (!isEntrySeparator(buffer[cursor])) {
cursor = skipEntry(buffer, cursor);
}
}
return { urls, end: cursor };
}
export function extractFileUrlsFromMpvEdlSource(source: string): string[] {
const buffer = toEdlDataBuffer(source);
const urls: string[] = [];
let cursor = 0;
while (cursor < buffer.length) {
cursor = skipEntrySeparators(buffer, cursor);
if (cursor >= buffer.length) {
break;
}
if (buffer[cursor] === BYTE_EXCLAMATION) {
cursor = skipEntry(buffer, cursor);
continue;
}
const segment = parseSegmentEntry(buffer, cursor);
urls.push(...segment.urls);
cursor = segment.end;
}
return urls;
}
@@ -272,6 +272,7 @@ export class PendingYoutubeMediaQueue {
job.endTime,
config.media?.audioPadding,
undefined,
config.media?.normalizeAudio !== false,
);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
+1
View File
@@ -92,6 +92,7 @@ test('loads defaults when config is missing', () => {
model: '',
systemPrompt: '',
});
assert.equal(config.ankiConnect.media.normalizeAudio, true);
assert.equal(config.startupWarmups.lowPowerMode, false);
assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true);
@@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
animatedMaxHeight: 0,
animatedCrf: 35,
syncAnimatedImageToWordAudio: true,
normalizeAudio: true,
audioPadding: 0,
fallbackDuration: 3.0,
maxMediaDuration: 30,
@@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', ()
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled',
'subtitleStyle.nameMatchEnabled',
'ankiConnect.media.normalizeAudio',
'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath',
'mpv.launchMode',
@@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
description: 'Generate sentence audio for mined cards.',
},
{
path: 'ankiConnect.media.normalizeAudio',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.media.normalizeAudio,
description: 'Normalize generated sentence audio loudness during media extraction.',
},
{
path: 'ankiConnect.media.generateImage',
kind: 'boolean',
@@ -311,7 +317,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'object',
defaultValue: defaultConfig.ankiConnect.knownWords.decks,
description:
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.',
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.',
},
{
path: 'ankiConnect.isKiku.fieldGrouping',
@@ -729,7 +729,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']);
assert.deepEqual(seenArgs, ['90d', 'month', true]);
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
});
@@ -747,7 +747,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['365d', 'month']);
assert.deepEqual(seenArgs, ['365d', 'month', true]);
});
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
@@ -763,7 +763,25 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day']);
assert.deepEqual(seenArgs, ['30d', 'day', true]);
});
it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
createMockTracker({
getTrendsDashboard: async (...args: unknown[]) => {
seenArgs = args;
return TRENDS_DASHBOARD;
},
}),
);
const res = await app.request(
'/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false',
);
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day', false]);
});
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
+40
View File
@@ -374,6 +374,46 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
);
});
test('handleCliCommand ensures background stats server for initial --start --background', () => {
const ensured: number[] = [];
const { deps } = createDeps({
ensureBackgroundStatsServer: () => {
ensured.push(1);
},
});
handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);
assert.equal(ensured.length, 1);
});
test('handleCliCommand ensures background stats server for second-instance --start --background', () => {
const ensured: number[] = [];
const { deps } = createDeps({
isOverlayRuntimeInitialized: () => true,
ensureBackgroundStatsServer: () => {
ensured.push(1);
},
});
handleCliCommand(makeArgs({ start: true, background: true }), 'second-instance', deps);
assert.equal(ensured.length, 1);
});
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
const ensured: number[] = [];
const { deps } = createDeps({
ensureBackgroundStatsServer: () => {
ensured.push(1);
},
});
handleCliCommand(makeArgs({ start: true }), 'initial', deps);
assert.equal(ensured.length, 0);
});
test('handleCliCommand forces setup open for second-instance setup command', () => {
const { deps, calls } = createDeps();
+7
View File
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
ensureBackgroundStatsServer?: () => void;
printHelp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
@@ -185,6 +186,7 @@ interface AnilistCliRuntime {
interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
ensureBackgroundStatsServer?: () => void;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
@@ -299,6 +301,7 @@ export function createCliCommandDepsRuntime(
runUpdateCommand: options.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: options.app.ensureBackgroundStatsServer,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
@@ -393,6 +396,10 @@ export function handleCliCommand(
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
}
if (args.start && args.background) {
deps.ensureBackgroundStatsServer?.();
}
if (args.sessionAction) {
dispatchCliSessionAction(
args.sessionAction,
@@ -245,6 +245,12 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
restoreOnModalClose: 'kiku',
preferModalWindow: true,
},
// Abandonment also asks the renderer hosting the modal to close its dialog.
{
channel: 'kiku:field-grouping-cancel',
restoreOnModalClose: undefined,
preferModalWindow: true,
},
],
);
assert.deepEqual(waitCalls, [
@@ -254,7 +260,76 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
assert.deepEqual(warnings, [
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
]);
assert.deepEqual(closed, ['kiku']);
// Once from the send-failure path inside sendKikuFieldGroupingRequest, once from the
// callback's abandonment cleanup. The real runtime guards this via the restore set, so
// the duplicate is a harmless no-op.
assert.deepEqual(closed, ['kiku', 'kiku']);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test('createFieldGroupingOverlayRuntime prepares overlay windows before opening the modal', async () => {
// The field grouping modal must run the same prerequisites as every other modal
// (openOverlayHostedModal) so it opens with the overlay runtime ready and the visible overlay
// window present — otherwise on Hyprland it fails to sit above / focus over fullscreen mpv.
const order: string[] = [];
const originalSetTimeout = globalThis.setTimeout;
// The modal acknowledges open below, so the callback stays pending and arms its response
// timeout; stub the timer so no real 90s handle leaks into the test runner.
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
try {
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: () => null,
setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
ensureOverlayStartupPrereqs: () => order.push('prereqs'),
ensureOverlayWindowsReadyForVisibilityActions: () => order.push('windows-ready'),
sendToVisibleOverlay: (channel) => {
order.push(`send:${channel}`);
return true;
},
waitForModalOpen: async () => {
order.push('wait');
return true;
},
});
// Do not await: an acknowledged modal leaves the choice pending until the user responds.
void runtime.createFieldGroupingCallback()({
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
});
// Let the async send + modal-open ack chain run.
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
}
assert.deepEqual(order, [
'prereqs',
'windows-ready',
'send:kiku:field-grouping-request',
'wait',
]);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
+32 -2
View File
@@ -1,4 +1,5 @@
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
interface WindowLike {
@@ -22,6 +23,15 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
handleOverlayModalClosed?: (modal: T) => void;
logWarn?: (message: string) => void;
/**
* Prepare the overlay runtime and (re)create the visible overlay window before opening the
* modal the same prerequisites every other modal runs via `openOverlayHostedModal`. Without
* them the field grouping modal can open with no sibling overlay window present, and on
* Hyprland it then fails to sit above / take focus over fullscreen mpv the way the other
* modals do.
*/
ensureOverlayStartupPrereqs?: () => void;
ensureOverlayWindowsReadyForVisibilityActions?: () => void;
sendToVisibleOverlay?: (
channel: string,
payload?: unknown,
@@ -69,11 +79,16 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
data: KikuFieldGroupingRequestData,
): Promise<boolean> => {
const kikuModal = 'kiku' as T;
const sendOpen = (): boolean =>
sendToVisibleOverlay('kiku:field-grouping-request', data, {
const sendOpen = (): boolean => {
// Match every other modal's open path (openOverlayHostedModal): ensure the overlay runtime
// and visible overlay window exist before handing off to the dedicated modal window.
options.ensureOverlayStartupPrereqs?.();
options.ensureOverlayWindowsReadyForVisibilityActions?.();
return sendToVisibleOverlay('kiku:field-grouping-request', data, {
restoreOnModalClose: kikuModal,
preferModalWindow: true,
});
};
if (!options.waitForModalOpen) {
return sendOpen();
@@ -102,6 +117,20 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
return opened;
};
const dismissModalUi = (): void => {
const kikuModal = 'kiku' as T;
// Best-effort: tell the renderer hosting the modal to close its dialog. When the modal
// lives in the dedicated modal window this is redundant with the teardown below, but it
// also covers the case where the request was routed into the visible overlay.
sendToVisibleOverlay(IPC_CHANNELS.event.kikuFieldGroupingCancel, undefined, {
preferModalWindow: true,
});
// Reliable teardown of main-side modal state (restore set, main-overlay passthrough,
// dedicated modal window). This is what recovers the frozen overlay when a grouping
// request times out or fails to reach a visible modal.
options.handleOverlayModalClosed?.(kikuModal);
};
const createFieldGroupingCallback = (): ((
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>) => {
@@ -112,6 +141,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
setResolver: options.setResolver,
sendToVisibleOverlay,
sendKikuFieldGroupingRequest,
dismissModalUi,
});
};
+272
View File
@@ -0,0 +1,272 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
import { createFieldGroupingCallback } from './field-grouping';
function makeRequestData(): KikuFieldGroupingRequestData {
return {
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'a',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
};
}
/**
* Mirrors how main stores the resolver: it wraps the callback's resolver in a
* sequence-guarded closure, so the value read back is never identity-equal to the
* callback's own `finish`. The old `getResolver() === finish` clear-guard therefore
* never matched and leaked the resolver, wedging every later grouping attempt.
*/
function createWrappedResolverStore() {
let stored: ((choice: KikuFieldGroupingChoice) => void) | null = null;
return {
getResolver: () => stored,
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
stored = resolver ? (choice) => resolver(choice) : null;
},
respond: (choice: KikuFieldGroupingChoice) => {
stored?.(choice);
},
};
}
test('field grouping callback clears the wrapped resolver after a renderer response', async () => {
const store = createWrappedResolverStore();
let visible = false;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => visible,
setVisibleOverlayVisible: (next) => {
visible = next;
},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
});
const pending = callback(makeRequestData());
await Promise.resolve();
assert.notEqual(store.getResolver(), null);
const choice: KikuFieldGroupingChoice = {
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: true,
cancelled: false,
};
store.respond(choice);
assert.deepEqual(await pending, choice);
assert.equal(store.getResolver(), null);
});
test('field grouping callback does not reject the next request after a response', async () => {
const store = createWrappedResolverStore();
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
});
const first = callback(makeRequestData());
await Promise.resolve();
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
const firstChoice = await first;
assert.equal(firstChoice.cancelled, false);
// The second attempt must reach the renderer, not short-circuit to an instant cancel.
const second = callback(makeRequestData());
await Promise.resolve();
assert.notEqual(store.getResolver(), null);
store.respond({ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false });
const secondChoice = await second;
assert.equal(secondChoice.cancelled, false);
assert.equal(secondChoice.keepNoteId, 2);
});
test('field grouping callback dismisses the modal UI when the send fails', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
dismissed += 1;
},
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
});
test('field grouping callback handles modal dismiss failures on send failure', async () => {
const store = createWrappedResolverStore();
const originalConsoleError = console.error;
console.error = () => {};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(store.getResolver(), null);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback handles modal dismiss failures on timeout', async () => {
const store = createWrappedResolverStore();
const originalConsoleError = console.error;
console.error = () => {};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
responseTimeoutMs: 5,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(store.getResolver(), null);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback reports modal dismiss failures', async () => {
const store = createWrappedResolverStore();
const errors: unknown[] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args);
};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
});
await callback(makeRequestData());
assert.equal(errors.length, 1);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback dismisses the modal UI when the response times out', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 5,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
});
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 10000,
});
const pending = callback(makeRequestData());
await Promise.resolve();
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
await pending;
assert.equal(dismissed, 0);
});
test('field grouping callback rejects a concurrent request while one is pending', async () => {
const store = createWrappedResolverStore();
let sends = 0;
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => {
sends += 1;
return true;
},
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 10000,
});
const first = callback(makeRequestData());
await Promise.resolve();
assert.equal(sends, 1);
const second = await callback(makeRequestData());
assert.equal(second.cancelled, true);
assert.equal(sends, 1);
assert.equal(dismissed, 0);
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
await first;
});
+42 -29
View File
@@ -1,21 +1,41 @@
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
const DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS = 90000;
export function createFieldGroupingCallback(options: {
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
/**
* Tears down the modal UI when the request is abandoned without a renderer response
* (send failure or response timeout). Without this the dedicated modal window, its
* restore-set entry, and the forced main-overlay passthrough stay orphaned which on
* Wayland leaves an invisible modal covering mpv and the overlay stuck unresponsive.
*/
dismissModalUi?: () => void;
responseTimeoutMs?: number;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
const cancelledChoice = (): KikuFieldGroupingChoice => ({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
const dismissModalUi = (): void => {
try {
options.dismissModalUi?.();
} catch (error) {
console.error('Failed to dismiss Kiku field grouping modal UI:', error);
}
};
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
return new Promise((resolve) => {
if (options.getResolver()) {
resolve({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
resolve(cancelledChoice());
return;
}
@@ -23,18 +43,26 @@ export function createFieldGroupingCallback(options: {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const finish = (choice: KikuFieldGroupingChoice): void => {
const finish = (choice: KikuFieldGroupingChoice, abandoned = false): void => {
if (settled) return;
settled = true;
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
if (options.getResolver() === finish) {
options.setResolver(null);
}
// Always release the resolver. Callers (main) wrap this in a sequence-guarded
// resolver, so an identity check against `finish` never matches and would leak
// the resolver — blocking every later grouping attempt with an instant cancel.
options.setResolver(null);
resolve(choice);
// When abandoned without a renderer response, tear down the modal window/state
// that the request path spun up. A normal response already routes through the
// renderer's close handler, so only the abandon paths need this.
if (abandoned) {
dismissModalUi();
}
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
options.setVisibleOverlayVisible(false);
}
@@ -45,32 +73,17 @@ export function createFieldGroupingCallback(options: {
(sent) => {
if (settled) return;
if (!sent) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
finish(cancelledChoice(), true);
return;
}
timeout = setTimeout(() => {
if (!settled) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
finish(cancelledChoice(), true);
}
}, 90000);
}, options.responseTimeoutMs ?? DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS);
},
() => {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
finish(cancelledChoice(), true);
},
);
});
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildHyprlandPlacementDispatches,
ensureHyprlandWindowFloatingByTitle,
ensureHyprlandWindowFloatingByTitleWithStatus,
findHyprlandWindowForPlacement,
hasHyprlandWindowPlacementBoundsMismatch,
shouldAttemptHyprlandWindowPlacement,
@@ -203,6 +204,67 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
);
});
test('ensureHyprlandWindowFloatingByTitleWithStatus reports not-applicable off Hyprland', () => {
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay Modal',
platform: 'linux',
env: {},
execFileSync: (() => {
throw new Error('should not query the compositor when placement is not applicable');
}) as never,
});
assert.deepEqual(status, { applicable: false, clientFound: false, dispatched: false });
});
test('ensureHyprlandWindowFloatingByTitleWithStatus reports pending when the client is not yet mapped', () => {
// The window has not been mapped by the compositor yet, so no client matches. Callers use
// this to keep retrying until the modal is actually placed above fullscreen mpv.
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay Modal',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 999,
execFileSync: ((command: string, args: string[]) => {
if (args.join(' ') === '-j clients') {
return JSON.stringify([]);
}
return '';
}) as never,
});
assert.deepEqual(status, { applicable: true, clientFound: false, dispatched: false });
});
test('ensureHyprlandWindowFloatingByTitleWithStatus reports the client found once mapped', () => {
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay Modal',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
execFileSync: ((command: string, args: string[]) => {
if (args.join(' ') === '-j clients') {
return JSON.stringify([
{
address: '0xmatch',
pid: 456,
title: 'SubMiner Overlay Modal',
mapped: true,
floating: false,
pinned: false,
},
]);
}
if (args.join(' ') === '-j status') {
return JSON.stringify({ configProvider: 'lua' });
}
return '';
}) as never,
});
assert.deepEqual(status, { applicable: true, clientFound: true, dispatched: true });
});
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
const calls: unknown[][] = [];
const placed = ensureHyprlandWindowFloatingByTitle({
+31 -4
View File
@@ -272,6 +272,21 @@ export function hasHyprlandWindowPlacementBoundsMismatch(options: {
}
}
/**
* Placement outcome for a single reconcile attempt.
* - `applicable`: false when not running under Hyprland (nothing to retry).
* - `clientFound`: whether a mapped Hyprland client matched. On Wayland the window maps
* asynchronously after `show()`, so early attempts can miss it callers retry while this
* is false so the modal actually gets promoted above fullscreen mpv instead of staying
* invisible.
* - `dispatched`: whether any hyprctl dispatch was issued.
*/
export interface HyprlandPlacementStatus {
applicable: boolean;
clientFound: boolean;
dispatched: boolean;
}
export function ensureHyprlandWindowFloatingByTitle(options: {
title: string;
bounds?: HyprlandPlacementBounds | null;
@@ -281,8 +296,20 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
promote?: boolean;
execFileSync?: ExecFileSync;
}): boolean {
return ensureHyprlandWindowFloatingByTitleWithStatus(options).dispatched;
}
export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
title: string;
bounds?: HyprlandPlacementBounds | null;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
pid?: number;
promote?: boolean;
execFileSync?: ExecFileSync;
}): HyprlandPlacementStatus {
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
return false;
return { applicable: false, clientFound: false, dispatched: false };
}
const run = options.execFileSync ?? execFileSync;
@@ -293,7 +320,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
title: options.title,
});
if (!client) {
return false;
return { applicable: true, clientFound: false, dispatched: false };
}
const configProvider = detectHyprlandConfigProvider(run);
@@ -329,9 +356,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
// Best-effort reconciliation: the initial placement dispatches already ran.
}
}
return dispatches.length > 0;
return { applicable: true, clientFound: true, dispatched: dispatches.length > 0 };
} catch {
return false;
return { applicable: true, clientFound: false, dispatched: false };
}
}
@@ -571,8 +571,9 @@ export class ImmersionTrackerService {
async getTrendsDashboard(
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day',
fillEmptyBuckets = true,
): Promise<unknown> {
return getTrendsDashboard(this.db, range, groupBy);
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
}
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
@@ -1003,6 +1003,121 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
}
});
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/30d-zerofill.mkv', {
canonicalTitle: '30d Zero Fill',
sourcePath: '/tmp/30d-zerofill.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const createdAtMs = '1772395200000';
// Local "today" for the mocked clock is epoch day 20513. Seed only two
// active days inside the 30-day window, leaving the rest empty.
const todayEpochDay = 20513;
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
insertDailyRollup.run(
todayEpochDay - 10,
videoId,
1,
45,
4,
120,
3,
createdAtMs,
createdAtMs,
);
const dashboard = getTrendsDashboard(db, '30d', 'day');
// Exactly 30 calendar days, not just the two active ones.
assert.equal(dashboard.activity.watchTime.length, 30);
// Most recent day carries its seeded value; a gap day reads zero.
assert.equal(dashboard.activity.watchTime.at(-1)?.value, 30);
assert.equal(dashboard.activity.watchTime.at(-2)?.value, 0);
// Only the two seeded days contribute to the totals.
const nonZeroDays = dashboard.activity.watchTime.filter((point) => point.value > 0);
assert.equal(nonZeroDays.length, 2);
// Cumulative watch time still tops out at the sum of both active days.
assert.equal(dashboard.progress.watchTime.at(-1)?.value, 75);
// Every day-bucketed series shares the same 30-day axis.
assert.equal(dashboard.progress.episodes.length, 30);
assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/no-zerofill.mkv', {
canonicalTitle: 'No Zero Fill',
sourcePath: '/tmp/no-zerofill.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const createdAtMs = '1772395200000';
const todayEpochDay = 20513;
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
insertDailyRollup.run(
todayEpochDay - 10,
videoId,
1,
45,
4,
120,
3,
createdAtMs,
createdAtMs,
);
const filled = getTrendsDashboard(db, '30d', 'day', true);
assert.equal(filled.activity.watchTime.length, 30);
const compact = getTrendsDashboard(db, '30d', 'day', false);
// Only the two active days survive; no zero-filled gaps.
assert.equal(compact.activity.watchTime.length, 2);
assert.ok(compact.activity.watchTime.every((point) => point.value > 0));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test(
'getTrendsDashboard supports 365d range and caps day buckets at 365',
{ timeout: 20_000 },
@@ -1266,7 +1381,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
const dashboard = getTrendsDashboard(db, '30d', 'month');
assert.equal(dashboard.activity.watchTime.length, 2);
// The 30d window (mocked now Mar 1 → cutoff Jan 31) spans three calendar
// months, so January is zero-filled rather than dropped.
assert.equal(dashboard.activity.watchTime.length, 3);
assert.equal(dashboard.activity.watchTime[0]?.value, 0);
assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
@@ -205,6 +205,59 @@ function resolveTrendAnimeTitle(value: {
return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
}
// Ordered list of bucket keys (epoch days or YYYYMM months) covering the
// selected range, so charts render one point per calendar bucket instead of
// silently collapsing to only the buckets that have activity. Returns null for
// the unbounded "all" range, where builders fall back to the buckets in data.
function buildBucketAxis(
db: DatabaseSync,
groupBy: TrendGroupBy,
cutoffMs: string | null,
referenceMs: string,
): number[] | null {
if (cutoffMs === null) {
return null;
}
if (groupBy === 'month') {
const startKey = getLocalMonthKey(db, cutoffMs);
const endKey = getLocalMonthKey(db, referenceMs);
const keys: number[] = [];
let year = Math.floor(startKey / 100);
let month = startKey % 100;
const endYear = Math.floor(endKey / 100);
const endMonth = endKey % 100;
while (year < endYear || (year === endYear && month <= endMonth)) {
keys.push(year * 100 + month);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return keys;
}
const startDay = getLocalEpochDay(db, cutoffMs);
const endDay = getLocalEpochDay(db, referenceMs);
const keys: number[] = [];
for (let day = startDay; day <= endDay; day += 1) {
keys.push(day);
}
return keys;
}
// Project a bucket→value map onto the axis, filling absent buckets with zero.
// Without an axis (the "all" range) it falls back to the populated buckets in
// ascending order, preserving the previous behaviour.
function fillAxisPoints(
axis: number[] | null,
valueByBucket: Map<number, number>,
): TrendChartPoint[] {
const keys = axis ?? [...valueByBucket.keys()].sort((left, right) => left - right);
return keys.map((key) => ({ label: makeTrendLabel(key), value: valueByBucket.get(key) ?? 0 }));
}
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
let sum = 0;
return points.map((point) => {
@@ -216,7 +269,7 @@ function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
});
}
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[], axis: number[] | null) {
const byKey = new Map<
number,
{ activeMin: number; cards: number; words: number; sessions: number }
@@ -236,15 +289,17 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
byKey.set(rollup.rollupDayOrMonth, existing);
}
return Array.from(byKey.entries())
.sort(([left], [right]) => left - right)
.map(([key, value]) => ({
const keys = axis ?? Array.from(byKey.keys()).sort((left, right) => left - right);
return keys.map((key) => {
const value = byKey.get(key) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0 };
return {
label: makeTrendLabel(key),
activeMin: Math.round(value.activeMin),
cards: value.cards,
words: value.words,
sessions: value.sessions,
}));
};
});
}
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
@@ -289,40 +344,24 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
}));
}
function dayLabel(epochDay: number): string {
const { month, day } = dayPartsFromEpochDay(epochDay);
return `${MONTH_NAMES[month - 1]} ${day}`;
}
function buildSessionSeriesByDay(
function buildSessionSeries(
sessions: TrendSessionMetricRow[],
groupBy: TrendGroupBy,
getValue: (session: TrendSessionMetricRow) => number,
axis: number[] | null,
): TrendChartPoint[] {
const byDay = new Map<number, number>();
const byBucket = new Map<number, number>();
for (const session of sessions) {
byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session));
const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
byBucket.set(bucketKey, (byBucket.get(bucketKey) ?? 0) + getValue(session));
}
return Array.from(byDay.entries())
.sort(([left], [right]) => left - right)
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
}
function buildSessionSeriesByMonth(
sessions: TrendSessionMetricRow[],
getValue: (session: TrendSessionMetricRow) => number,
): TrendChartPoint[] {
const byMonth = new Map<number, number>();
for (const session of sessions) {
byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session));
}
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
return fillAxisPoints(axis, byBucket);
}
function buildLookupsPerHundredWords(
sessions: TrendSessionMetricRow[],
groupBy: TrendGroupBy,
axis: number[] | null,
): TrendChartPoint[] {
const lookupsByBucket = new Map<number, number>();
const wordsByBucket = new Map<number, number>();
@@ -339,15 +378,12 @@ function buildLookupsPerHundredWords(
);
}
return Array.from(lookupsByBucket.entries())
.sort(([left], [right]) => left - right)
.map(([bucketKey, lookups]) => {
const words = wordsByBucket.get(bucketKey) ?? 0;
return {
label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey),
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
};
});
const ratioByBucket = new Map<number, number>();
for (const [bucketKey, lookups] of lookupsByBucket) {
const words = wordsByBucket.get(bucketKey) ?? 0;
ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0);
}
return fillAxisPoints(axis, ratioByBucket);
}
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
@@ -557,46 +593,26 @@ function buildEpisodesPerAnimeFromDailyRollups(
return result;
}
function buildEpisodesPerDayFromDailyRollups(
function buildEpisodesSeriesFromRollups(
rollups: ImmersionSessionRollupRow[],
axis: number[] | null,
): TrendChartPoint[] {
const byDay = new Map<number, Set<number>>();
const byBucket = new Map<number, Set<number>>();
for (const rollup of rollups) {
if (rollup.videoId === null) {
continue;
}
const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set<number>();
const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set<number>();
videoIds.add(rollup.videoId);
byDay.set(rollup.rollupDayOrMonth, videoIds);
byBucket.set(rollup.rollupDayOrMonth, videoIds);
}
return Array.from(byDay.entries())
.sort(([left], [right]) => left - right)
.map(([epochDay, videoIds]) => ({
label: dayLabel(epochDay),
value: videoIds.size,
}));
}
function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): TrendChartPoint[] {
const byMonth = new Map<number, Set<number>>();
for (const rollup of rollups) {
if (rollup.videoId === null) {
continue;
}
const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set<number>();
videoIds.add(rollup.videoId);
byMonth.set(rollup.rollupDayOrMonth, videoIds);
const counts = new Map<number, number>();
for (const [bucketKey, videoIds] of byBucket) {
counts.set(bucketKey, videoIds.size);
}
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
.map(([monthKey, videoIds]) => ({
label: makeTrendLabel(monthKey),
value: videoIds.size,
}));
return fillAxisPoints(axis, counts);
}
function getTrendSessionMetrics(
@@ -639,7 +655,11 @@ function getTrendSessionMetrics(
}));
}
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
function buildNewWordsPerDay(
db: DatabaseSync,
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
@@ -662,13 +682,15 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendCh
wordCount: number;
}>;
return rows.map((row) => ({
label: dayLabel(row.epochDay),
value: row.wordCount,
}));
const byBucket = new Map<number, number>(rows.map((row) => [row.epochDay, row.wordCount]));
return fillAxisPoints(axis, byBucket);
}
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
function buildNewWordsPerMonth(
db: DatabaseSync,
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
@@ -691,16 +713,15 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): Trend
wordCount: number;
}>;
return rows.map((row) => ({
label: makeTrendLabel(row.monthKey),
value: row.wordCount,
}));
const byBucket = new Map<number, number>(rows.map((row) => [row.monthKey, row.wordCount]));
return fillAxisPoints(axis, byBucket);
}
export function getTrendsDashboard(
db: DatabaseSync,
range: TrendRange = '30d',
groupBy: TrendGroupBy = 'day',
fillEmptyBuckets = true,
): TrendsDashboardQueryResult {
const dayLimit = getTrendDayLimit(range);
const monthlyLimit = getTrendMonthlyLimit(db, range);
@@ -708,6 +729,11 @@ export function getTrendsDashboard(
const useMonthlyBuckets = groupBy === 'month';
const dailyRollups = getDailyRollups(db, dayLimit);
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
// A null axis makes the builders fall back to only the buckets present in the
// data; the contiguous axis zero-fills every calendar bucket in the window.
const bucketAxis = fillEmptyBuckets
? buildBucketAxis(db, groupBy, cutoffMs, currentDbTimestamp())
: null;
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
const sessions = getTrendSessionMetrics(db, cutoffMs);
@@ -716,7 +742,7 @@ export function getTrendsDashboard(
dailyRollups.map((rollup) => rollup.videoId),
);
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis);
const efficiency = buildEfficiencyRates(aggregatedRows);
const activity = {
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
@@ -751,22 +777,23 @@ export function getTrendsDashboard(
sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words),
newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
useMonthlyBuckets
? buildNewWordsPerMonth(db, cutoffMs, bucketAxis)
: buildNewWordsPerDay(db, cutoffMs, bucketAxis),
),
cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
buildEpisodesSeriesFromRollups(
useMonthlyBuckets ? monthlyRollups : dailyRollups,
bucketAxis,
),
),
lookups: accumulatePoints(
useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis),
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis),
cardsPerHour: efficiency.cardsPerHour,
readingSpeed: efficiency.readingSpeed,
},
+2
View File
@@ -65,12 +65,14 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
) => boolean;
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
dismissModalUi?: () => void;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
getResolver: options.getResolver,
setResolver: options.setResolver,
dismissModalUi: options.dismissModalUi,
sendRequestToVisibleOverlay: (data) =>
options.sendKikuFieldGroupingRequest
? options.sendKikuFieldGroupingRequest(data)
+8 -6
View File
@@ -1,6 +1,7 @@
import type { BrowserWindow } from 'electron';
import { RuntimeOptionState, WindowGeometry } from '../../types';
import { updateOverlayWindowBounds } from './overlay-window';
import type { HyprlandPlacementStatus } from './hyprland-window-placement';
export interface OverlayManager {
getMainWindow: () => BrowserWindow | null;
@@ -9,7 +10,7 @@ export interface OverlayManager {
setModalWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: () => BrowserWindow | null;
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getOverlayWindows: () => BrowserWindow[];
@@ -29,9 +30,12 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
let visibleOverlayVisible = false;
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => {
const updateWindowBounds = (
geometry: WindowGeometry,
window: BrowserWindow | null,
): HyprlandPlacementStatus => {
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
applyOverlayBounds(geometry, window, { promote });
return applyOverlayBounds(geometry, window, { promote });
};
return {
@@ -47,9 +51,7 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
setOverlayWindowBounds: (geometry) => {
updateWindowBounds(geometry, mainWindow);
},
setModalWindowBounds: (geometry) => {
updateWindowBounds(geometry, modalWindow);
},
setModalWindowBounds: (geometry) => updateWindowBounds(geometry, modalWindow),
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible;
+10 -4
View File
@@ -9,7 +9,11 @@ import {
handleOverlayWindowBlurred,
type OverlayWindowKind,
} from './overlay-window-input';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
import {
ensureHyprlandWindowFloatingByTitle,
ensureHyprlandWindowFloatingByTitleWithStatus,
type HyprlandPlacementStatus,
} 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';
@@ -54,8 +58,10 @@ export function updateOverlayWindowBounds(
options: {
promote?: boolean;
} = {},
): void {
if (!geometry || !window || window.isDestroyed()) return;
): HyprlandPlacementStatus {
if (!geometry || !window || window.isDestroyed()) {
return { applicable: false, clientFound: false, dispatched: false };
}
const bounds = normalizeOverlayWindowBoundsForPlatform(
geometry,
process.platform,
@@ -63,7 +69,7 @@ export function updateOverlayWindowBounds(
window,
);
window.setBounds(bounds);
ensureHyprlandWindowFloatingByTitle({
return ensureHyprlandWindowFloatingByTitleWithStatus({
title: window.getTitle(),
bounds,
promote: options.promote,
+17 -2
View File
@@ -81,6 +81,12 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
return raw === 'month' ? 'month' : 'day';
}
// Defaults to true (zero-fill empty calendar buckets); only an explicit
// "false" opts into the compact, data-only view.
function parseTrendFillEmpty(raw: string | undefined): boolean {
return raw !== 'false';
}
function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
if (!raw) return undefined;
const parsed = raw
@@ -685,7 +691,8 @@ export function createStatsApp(
app.get('/api/stats/trends/dashboard', async (c) => {
const range = parseTrendRange(c.req.query('range'));
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
return c.json(await tracker.getTrendsDashboard(range, groupBy));
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
});
app.get('/api/stats/sessions', async (c) => {
@@ -1206,6 +1213,7 @@ export function createStatsApp(
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
const startSec = startMs / 1000;
@@ -1228,7 +1236,14 @@ export function createStatsApp(
const audioPromise = generateAudio
? timeMiningPhase(mode, 'generateAudio', () =>
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
mediaGen.generateAudio(
sourcePath,
startSec,
clampedEndSec,
audioPadding,
null,
normalizeAudio,
),
)
: Promise.resolve(null);
+4 -3
View File
@@ -42,7 +42,7 @@ export interface TokenizerServiceDeps {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
isKnownWord: (text: string, reading?: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
@@ -77,7 +77,7 @@ export interface TokenizerDepsRuntimeOptions {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
isKnownWord: (text: string, reading?: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
@@ -129,7 +129,7 @@ const INVISIBLE_SEPARATOR_PATTERN = /[\u200b\u2060\ufeff]/g;
function getKnownWordLookup(
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): (text: string) => boolean {
): (text: string, reading?: string) => boolean {
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
return () => false;
}
@@ -723,6 +723,7 @@ async function parseWithYomitanInternalParser(
surface: token.surface,
reading: token.reading,
headword: token.headword,
headwordReading: token.headwordReading,
startPos: token.startPos,
endPos: token.endPos,
partOfSpeech: posMetadata.partOfSpeech,
@@ -56,6 +56,70 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
assert.equal(surfaceResult[0]?.isKnown, false);
});
test('annotateTokens passes dictionary-form reading so spelling collisions stay unknown', () => {
// とこ (colloquial ところ) resolves to headword 床/とこ; a known 床/ゆか card
// must not mark it known (#138 regression).
const cache = new Map([['床', 'ゆか']]);
const isKnownWord = (text: string, reading?: string): boolean => {
if (!cache.has(text)) {
return false;
}
return reading === undefined || cache.get(text) === reading;
};
const tokens = [
makeToken({
surface: 'とこ',
headword: '床',
reading: 'とこ',
headwordReading: 'とこ',
endPos: 2,
}),
];
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
assert.equal(result[0]?.isKnown, false);
});
test('annotateTokens keeps inflected known words matched via headword reading', () => {
const isKnownWord = (text: string, reading?: string): boolean =>
text === '行く' && (reading === undefined || reading === 'いく');
const tokens = [
makeToken({
surface: '行きたい',
headword: '行く',
reading: 'いきたい',
headwordReading: 'いく',
partOfSpeech: PartOfSpeech.verb,
endPos: 4,
}),
];
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
assert.equal(result[0]?.isKnown, true);
});
test('annotateTokens omits reading for headword match when token lacks headword reading and is inflected', () => {
// MeCab tokens have no dictionary-form reading; the surface reading of an
// inflected form must not be compared against the note's dictionary reading.
const isKnownWord = (text: string, reading?: string): boolean =>
text === '食べる' && reading === undefined;
const tokens = [
makeToken({
surface: '食べた',
headword: '食べる',
reading: 'タベタ',
partOfSpeech: PartOfSpeech.verb,
endPos: 3,
}),
];
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
assert.equal(result[0]?.isKnown, true);
});
test('annotateTokens marks known words when N+1 is disabled', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
@@ -123,6 +187,48 @@ test('annotateTokens falls back to reading for known-word matches when headword
assert.equal(result[0]?.frequencyRank, 1895);
});
test('annotateTokens ignores partial furigana readings for known-word fallback', () => {
const tokens = [
makeToken({
surface: '待ち合わせてる',
headword: '待ち合わせる',
reading: 'まあ',
partOfSpeech: PartOfSpeech.verb,
endPos: 7,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'まあ',
}),
);
assert.equal(result[0]?.isKnown, false);
});
test('annotateTokens reading fallback still matches kana surfaces with complete readings', () => {
const tokens = [
makeToken({
surface: 'ください',
headword: '下さい',
reading: 'ください',
partOfSpeech: PartOfSpeech.verb,
endPos: 4,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'ください',
}),
);
assert.equal(result[0]?.isKnown, true);
});
test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 exclusions', () => {
const tokens = [
makeToken({
+75 -14
View File
@@ -25,7 +25,7 @@ const jlptLevelLookupCaches = new WeakMap<
>();
export interface AnnotationStageDeps {
isKnownWord: (text: string) => boolean;
isKnownWord: (text: string, reading?: string) => boolean;
knownWordMatchMode: NPlusOneMatchMode;
getJlptLevel: (text: string) => JlptLevel | null;
}
@@ -635,22 +635,83 @@ export function stripSubtitleAnnotationMetadata(
return sharedStripSubtitleAnnotationMetadata(token, options);
}
function computeTokenKnownStatus(
token: MergedToken,
isKnownWord: (text: string) => boolean,
knownWordMatchMode: NPlusOneMatchMode,
): boolean {
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
if (token.isKnown || (matchText ? isKnownWord(matchText) : false)) {
return true;
}
const normalizedReading = token.reading.trim();
if (!normalizedReading) {
// Furigana-derived readings can be partial (kanji readings only, e.g. まあ for
// 待ち合わせてる); matching those against known words produces false positives,
// so the reading fallback requires a reading that plausibly covers the surface:
// at least as many characters as the surface, with the surface's kana appearing
// in order within the reading.
function isCompleteReadingForSurface(surface: string, reading: string): boolean {
const surfaceChars = [...normalizeJlptTextForExclusion(surface)];
const readingChars = [...normalizeJlptTextForExclusion(reading)];
if (readingChars.length < surfaceChars.length) {
return false;
}
return normalizedReading !== matchText.trim() && isKnownWord(normalizedReading);
let cursor = 0;
for (const char of surfaceChars) {
if (!isKanaChar(char)) {
continue;
}
const foundAt = readingChars.indexOf(char, cursor);
if (foundAt === -1) {
return false;
}
cursor = foundAt + 1;
}
return true;
}
// Returns the token's trimmed reading only when it plausibly covers the surface
// (see isCompleteReadingForSurface); undefined otherwise. Shared so the
// known-word reading disambiguation and the reading fallback stay in sync if the
// validity rule changes.
function resolveCompleteTokenReading(token: MergedToken): string | undefined {
const normalizedReading = token.reading.trim();
if (!normalizedReading || !isCompleteReadingForSurface(token.surface, normalizedReading)) {
return undefined;
}
return normalizedReading;
}
// Reading to disambiguate the known-word text match, or undefined when the
// token has no reading that describes the match text: in headword mode an
// inflected surface's reading does not match the dictionary form's reading,
// and partial furigana readings (see isCompleteReadingForSurface) would cause
// false negatives. Undefined falls back to text-only matching (fail-open).
function resolveKnownWordReadingForMatch(
token: MergedToken,
knownWordMatchMode: NPlusOneMatchMode,
): string | undefined {
if (knownWordMatchMode === 'headword') {
const headwordReading = token.headwordReading?.trim();
if (headwordReading) {
return headwordReading;
}
if (token.surface !== token.headword) {
return undefined;
}
}
return resolveCompleteTokenReading(token);
}
function computeTokenKnownStatus(
token: MergedToken,
isKnownWord: (text: string, reading?: string) => boolean,
knownWordMatchMode: NPlusOneMatchMode,
): boolean {
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
const matchReading = resolveKnownWordReadingForMatch(token, knownWordMatchMode);
if (token.isKnown || (matchText ? isKnownWord(matchText, matchReading) : false)) {
return true;
}
const fallbackReading = resolveCompleteTokenReading(token);
if (!fallbackReading) {
return false;
}
return fallbackReading !== matchText.trim() && isKnownWord(fallbackReading);
}
function filterTokenFrequencyRank(
@@ -964,8 +964,9 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
assert.deepEqual(result, [
{
surface: '潜み',
reading: 'ひそ',
reading: 'ひそ',
headword: '潜む',
headwordReading: 'ひそむ',
startPos: 0,
endPos: 2,
isNameMatch: false,
@@ -974,6 +975,73 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
]);
});
test('requestYomitanScanTokens emits complete readings for kanji-kana compounds', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
if (script.includes('termsFind')) {
scannerScript = script;
return [];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [{ name: 'JPDBv2㋕', enabled: true, id: 0 }],
},
},
],
};
}
return null;
});
await requestYomitanScanTokens('待ち合わせてる', deps, {
error: () => undefined,
});
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text ?? '';
if (!text.startsWith('待ち合わせてる')) {
return { originalTextLength: 0, dictionaryEntries: [] };
}
return {
originalTextLength: 7,
dictionaryEntries: [
{
headwords: [
{
term: '待ち合わせる',
reading: 'まちあわせる',
sources: [{ originalText: '待ち合わせてる', isPrimary: true, matchType: 'exact' }],
},
],
},
],
};
});
assert.deepEqual(result, [
{
surface: '待ち合わせてる',
reading: 'まちあわせてる',
headword: '待ち合わせる',
headwordReading: 'まちあわせる',
startPos: 0,
endPos: 7,
isNameMatch: false,
frequencyRank: undefined,
},
]);
});
test('requestYomitanScanTokens uses frequency from later exact-match entry when first exact entry has none', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
@@ -1073,6 +1141,7 @@ test('requestYomitanScanTokens uses frequency from later exact-match entry when
surface: '者',
reading: 'もの',
headword: '者',
headwordReading: 'もの',
startPos: 0,
endPos: 1,
isNameMatch: false,
@@ -1174,6 +1243,7 @@ test('requestYomitanScanTokens can use frequency from later exact secondary-matc
surface: '者',
reading: 'もの',
headword: '者',
headwordReading: 'もの',
startPos: 0,
endPos: 1,
isNameMatch: false,
@@ -1274,6 +1344,7 @@ test('requestYomitanScanTokens uses exact frequency entry when selected reading
surface: '第二',
reading: 'だいに',
headword: '第二',
headwordReading: 'だいに',
startPos: 0,
endPos: 2,
isNameMatch: false,
@@ -51,6 +51,7 @@ export interface YomitanScanToken {
surface: string;
reading: string;
headword: string;
headwordReading?: string;
startPos: number;
endPos: number;
isNameMatch?: boolean;
@@ -92,6 +93,7 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
typeof entry.surface === 'string' &&
typeof entry.reading === 'string' &&
typeof entry.headword === 'string' &&
(entry.headwordReading === undefined || typeof entry.headwordReading === 'string') &&
typeof entry.startPos === 'number' &&
typeof entry.endPos === 'number' &&
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
@@ -817,6 +819,12 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
function isCodePointKana(codePoint) { return isCodePointInRanges(codePoint, KANA_RANGES); }
function isCodePointJapanese(codePoint) { return isCodePointInRanges(codePoint, JAPANESE_RANGES); }
function createFuriganaSegment(text, reading) { return {text, reading}; }
function getSegmentReadingContribution(segment) {
if (typeof segment.reading === "string" && segment.reading.length > 0) { return segment.reading; }
const segmentText = typeof segment.text === "string" ? segment.text : "";
const isKanaOnly = segmentText.length > 0 && [...segmentText].every((char) => isCodePointKana(char.codePointAt(0)));
return isKanaOnly ? segmentText : "";
}
function getProlongedHiragana(previousCharacter) {
switch (previousCharacter) {
case "あ": case "か": case "が": case "さ": case "ざ": case "た": case "だ": case "な": case "は": case "ば": case "ぱ": case "ま": case "や": case "ら": case "わ": case "ぁ": case "ゃ": case "ゎ": return "あ";
@@ -1310,8 +1318,9 @@ ${YOMITAN_SCANNING_HELPERS}
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
const tokenPayload = {
surface: segments.map((segment) => segment.text).join("") || source,
reading: segments.map((segment) => typeof segment.reading === "string" ? segment.reading : "").join(""),
reading: segments.map(getSegmentReadingContribution).join(""),
headword: preferredHeadword.term,
headwordReading: reading || undefined,
startPos: i,
endPos: i + originalTextLength,
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
+14 -1
View File
@@ -37,6 +37,7 @@ import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { startAppControlServer } from './main/runtime/app-control-server';
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
import {
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
@@ -2324,6 +2325,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
logWarn: (message) => logger.warn(message),
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
ensureOverlayWindowsReadyForVisibilityActions(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
})(),
@@ -4066,6 +4070,14 @@ const statsStartupRuntime = {
}
},
} as const;
const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({
isStatsAutoStartEnabled: () => getResolvedConfig().stats.autoStartServer,
isImmersionTrackingEnabled: () => getResolvedConfig().immersionTracking?.enabled !== false,
ensureBackgroundStatsServerStarted: () =>
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
logInfo: (message) => logger.info(message),
logWarn: (message, error) => logger.warn(message, error),
});
const runStatsCliCommand = createRunStatsCliCommandHandler({
getResolvedConfig: () => getResolvedConfig(),
@@ -4635,7 +4647,7 @@ const {
setYomitanParserInitPromise: (promise) => {
appState.yomitanParserInitPromise = promise;
},
isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)),
isKnownWord: (text, reading) => Boolean(appState.ankiIntegration?.isKnownWord(text, reading)),
recordLookup: (hit) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordLookup(hit);
@@ -5962,6 +5974,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
);
},
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
+2
View File
@@ -47,6 +47,7 @@ export interface CliCommandRuntimeServiceContext {
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -124,6 +125,7 @@ function createCliCommandDepsFromContext(
app: {
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
ensureBackgroundStatsServer: context.ensureBackgroundStatsServer,
runUpdateCommand: context.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
+2
View File
@@ -200,6 +200,7 @@ export interface CliCommandRuntimeServiceDepsParams {
app: {
stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
ensureBackgroundStatsServer?: CliCommandDepsRuntimeOptions['app']['ensureBackgroundStatsServer'];
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
@@ -402,6 +403,7 @@ export function createCliCommandRuntimeServiceDeps(
app: {
stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow,
ensureBackgroundStatsServer: params.app.ensureBackgroundStatsServer,
runUpdateCommand: params.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
+153
View File
@@ -926,3 +926,156 @@ test('waitForModalOpen resolves false on timeout', async () => {
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
});
test('modal placement reconcile retries until the Hyprland client is mapped', () => {
const window = createMockWindow();
const timers: Array<() => void> = [];
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = ((cb: () => void) => {
timers.push(cb);
return { unref() {} };
}) as unknown as typeof globalThis.setTimeout;
const statuses: Array<{ applicable: boolean; clientFound: boolean }> = [];
try {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {
// The compositor never maps the window, so every reconcile reports pending.
const status = { applicable: true, clientFound: false, dispatched: false };
statuses.push(status);
return status;
},
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
runtime.notifyOverlayModalOpened('kiku');
let iterations = 0;
while (timers.length > 0 && iterations < 50) {
const next = timers.shift();
next?.();
iterations += 1;
}
// The reconcile ladder re-asserts placement across all six delays while the client
// stays unmapped, instead of the old single post-show attempt.
assert.ok(
statuses.length >= 6,
`expected at least 6 pending reconcile attempts, saw ${statuses.length}`,
);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test('modal placement reconcile stops retrying once the client is mapped', () => {
const window = createMockWindow();
const timers: Array<() => void> = [];
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = ((cb: () => void) => {
timers.push(cb);
return { unref() {} };
}) as unknown as typeof globalThis.setTimeout;
let reconcileCount = 0;
try {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {
reconcileCount += 1;
// Client is already mapped, so placement is settled on the first attempt.
return { applicable: true, clientFound: true, dispatched: true };
},
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
runtime.notifyOverlayModalOpened('kiku');
let iterations = 0;
while (timers.length > 0 && iterations < 50) {
const next = timers.shift();
next?.();
iterations += 1;
}
// No 6-deep ladder: a settled placement should not keep rescheduling.
assert.ok(
reconcileCount < 6,
`expected the ladder to stop early, saw ${reconcileCount} reconcile attempts`,
);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test('modal placement reconcile cancels stale retry ladder after a newer visible modal interaction', () => {
const window = createMockWindow();
type TimerEntry = { active: boolean; callback: () => void };
const timers: TimerEntry[] = [];
const activeTimerCount = () => timers.filter((timer) => timer.active).length;
const runNextActiveTimer = () => {
const timer = timers.find((candidate) => candidate.active);
if (!timer) return;
timer.active = false;
timer.callback();
};
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
globalThis.setTimeout = ((cb: () => void) => {
const timer = { active: true, callback: cb, unref() {} };
timers.push(timer);
return timer;
}) as unknown as typeof globalThis.setTimeout;
globalThis.clearTimeout = ((timeout: TimerEntry | undefined) => {
if (timeout) {
timeout.active = false;
}
}) as unknown as typeof globalThis.clearTimeout;
try {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => ({ applicable: true, clientFound: false, dispatched: false }),
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
runtime.notifyOverlayModalOpened('kiku');
assert.equal(activeTimerCount(), 1);
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
assert.equal(activeTimerCount(), 2);
runNextActiveTimer();
assert.equal(activeTimerCount(), 1, 'stale retry should not schedule a continuation');
} finally {
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
+42 -9
View File
@@ -1,10 +1,14 @@
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';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS = 50;
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
// before the compositor has a client to place, leaving the modal buried under fullscreen mpv.
// Re-assert placement across this ladder until the Hyprland client is found (or attempts run out).
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS = [50, 120, 250, 500, 900, 1400];
function requestOverlayApplicationFocus(): void {
try {
@@ -31,7 +35,7 @@ export interface OverlayWindowResolver {
getModalWindow: () => BrowserWindow | null;
createModalWindow: () => BrowserWindow | null;
getModalGeometry: () => WindowGeometry;
setModalWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus | void;
}
export interface OverlayModalRuntime {
@@ -73,6 +77,7 @@ export function createOverlayModalRuntimeService(
let modalWindowPrimedForImmediateShow = false;
let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
@@ -145,21 +150,47 @@ export function createOverlayModalRuntimeService(
window.moveTop();
};
const reconcileModalWindowBounds = (window: BrowserWindow): void => {
const reconcileModalWindowBounds = (window: BrowserWindow): HyprlandPlacementStatus | void => {
const modalWindow = deps.getModalWindow();
if (!modalWindow || modalWindow !== window || window.isDestroyed()) {
return;
}
deps.setModalWindowBounds(deps.getModalGeometry());
return deps.setModalWindowBounds(deps.getModalGeometry());
};
const scheduleModalWindowBoundsReconcile = (window: BrowserWindow): void => {
const nextModalWindowBoundsReconcileGeneration = (window: BrowserWindow): number => {
const generation = (modalWindowBoundsReconcileGenerations.get(window) ?? 0) + 1;
modalWindowBoundsReconcileGenerations.set(window, generation);
return generation;
};
const isCurrentModalWindowBoundsReconcileGeneration = (
window: BrowserWindow,
generation: number,
): boolean => modalWindowBoundsReconcileGenerations.get(window) === generation;
const scheduleModalWindowBoundsReconcile = (
window: BrowserWindow,
generation: number,
attempt = 0,
): void => {
if (attempt >= MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS.length) {
return;
}
const timeout = setTimeout(() => {
if (!isCurrentModalWindowBoundsReconcileGeneration(window, generation)) {
return;
}
if (window.isDestroyed() || !window.isVisible()) {
return;
}
reconcileModalWindowBounds(window);
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS);
const status = reconcileModalWindowBounds(window);
// Keep retrying only while a Hyprland placement is applicable but the compositor has
// not mapped the window yet. Once the client is found (or we're not on Hyprland), stop.
if (status && status.applicable && !status.clientFound) {
scheduleModalWindowBoundsReconcile(window, generation, attempt + 1);
}
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS[attempt]);
timeout.unref?.();
};
@@ -206,8 +237,9 @@ export function createOverlayModalRuntimeService(
if (!window.webContents.isFocused()) {
window.webContents.focus();
}
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
reconcileModalWindowBounds(window);
scheduleModalWindowBoundsReconcile(window);
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
};
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
@@ -219,8 +251,9 @@ export function createOverlayModalRuntimeService(
if (window.isVisible()) {
window.focus();
window.webContents.focus();
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
reconcileModalWindowBounds(window);
scheduleModalWindowBoundsReconcile(window);
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
return;
}
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createEnsureBackgroundStatsServerHandler } from './background-stats-startup';
function createDeps(
overrides: Partial<Parameters<typeof createEnsureBackgroundStatsServerHandler>[0]> = {},
) {
const calls: string[] = [];
const deps: Parameters<typeof createEnsureBackgroundStatsServerHandler>[0] = {
isStatsAutoStartEnabled: () => true,
isImmersionTrackingEnabled: () => true,
ensureBackgroundStatsServerStarted: () => {
calls.push('ensureBackgroundStatsServerStarted');
return { url: 'http://127.0.0.1:3888', runningInCurrentProcess: true };
},
logInfo: (message) => {
calls.push(`info:${message}`);
},
logWarn: (message) => {
calls.push(`warn:${message}`);
},
...overrides,
};
return { deps, calls };
}
test('ensures background stats server and logs local startup', () => {
const { deps, calls } = createDeps();
createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(calls.includes('ensureBackgroundStatsServerStarted'));
assert.ok(
calls.some((value) => value.startsWith('info:') && value.includes('http://127.0.0.1:3888')),
);
});
test('logs reuse when a background stats server is already running', () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: () => ({
url: 'http://127.0.0.1:3888',
runningInCurrentProcess: false,
}),
});
createEnsureBackgroundStatsServerHandler(deps)();
assert.ok(
calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)),
);
});
test('skips when stats.autoStartServer is disabled', () => {
const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false });
createEnsureBackgroundStatsServerHandler(deps)();
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
});
test('skips when immersion tracking is disabled', () => {
const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false });
createEnsureBackgroundStatsServerHandler(deps)();
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
});
test('logs a warning instead of throwing when startup fails', () => {
const { deps, calls } = createDeps({
ensureBackgroundStatsServerStarted: () => {
throw new Error('port in use');
},
});
assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)());
assert.ok(calls.some((value) => value.startsWith('warn:')));
});
@@ -0,0 +1,35 @@
export interface EnsureBackgroundStatsServerDeps {
isStatsAutoStartEnabled: () => boolean;
isImmersionTrackingEnabled: () => boolean;
ensureBackgroundStatsServerStarted: () => {
url: string;
runningInCurrentProcess: boolean;
};
logInfo: (message: string) => void;
logWarn: (message: string, error?: unknown) => void;
}
export function createEnsureBackgroundStatsServerHandler(
deps: EnsureBackgroundStatsServerDeps,
): () => void {
return () => {
if (!deps.isStatsAutoStartEnabled()) {
deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.');
return;
}
if (!deps.isImmersionTrackingEnabled()) {
deps.logInfo('Background start: immersion tracking is disabled; skipping stats server.');
return;
}
try {
const result = deps.ensureBackgroundStatsServerStarted();
deps.logInfo(
result.runningInCurrentProcess
? `Background start: stats server started at ${result.url}.`
: `Background start: stats server already running at ${result.url}; skipping.`,
);
} catch (error) {
deps.logWarn('Background start: failed to start stats server.', error);
}
};
}
@@ -45,6 +45,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -103,6 +104,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runUpdateCommand: deps.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -61,6 +61,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
@@ -140,6 +141,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
+2
View File
@@ -50,6 +50,7 @@ export type CliCommandContextFactoryDeps = {
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceContext['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -130,6 +131,7 @@ export function createCliCommandContext(
runUpdateCommand: deps.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,

Some files were not shown because too many files have changed in this diff Show More