Compare commits

...

10 Commits

Author SHA1 Message Date
sudacode 08c6807cb1 fix(launcher): normalize rofi prompt spacing
- Add formatRofiPrompt to trim trailing whitespace and append a single space, keeping the prompt from running into the input field
- Apply it across all rofi -p usages in picker.ts and history-command.ts
- Add tests for formatRofiPrompt edge cases (empty/whitespace-only prompts)
2026-07-26 00:13:54 -07:00
sudacode 18c6410f24 feat(launcher): add previous episode option to history entry menu
- Extract buildHistoryEntryActions to build the series action menu, now including a previous-episode option alongside replay/next/browse/quit
- Update docs and changelog entry to describe the new option
- Add tests covering previous/replay/next ordering and omission when last watched file is missing
2026-07-25 23:35:44 -07:00
sudacode e223cf9b71 fix(release): restore compatible js-yaml override 2026-07-22 23:51:21 -07:00
sudacode c718bb8b34 chore(release): prepare 0.19.0-beta.2 2026-07-22 23:34:08 -07:00
sudacode 177f7e5fd6 docs(readme): remove TsukiHime credit
- Drop the TsukiHime row from the open-source credits table
2026-07-22 23:07:07 -07:00
sudacode 20b50ec4e1 feat(launcher): add post-playback history menu with previous episode (#170) 2026-07-22 23:05:53 -07:00
sudacode deae61b211 refactor: split anki-connect and stats-server resolvers into modules (#169) 2026-07-17 23:05:59 -07:00
sudacode 44959ed282 fix(anki,appimage): handle proxy port conflicts and stray GPU child
- AnkiConnect proxy now detects EADDRINUSE, logs a warning, and surfaces an overlay notification instead of crashing video startup
- Background AppImage bootstrap runs Chromium with in-process-gpu so no GPU child survives app.exit() and outlives the FUSE mount, fixing the remaining DrKonqi "Service Crash" case
- Add changelog fragment for the proxy fix; update the AppImage quit fragment with the GPU child root cause
2026-07-17 23:04:56 -07:00
sudacode 2398f5c030 refactor(main): eliminate local pass-through wrappers in main.ts (#168) 2026-07-15 03:22:03 -07:00
sudacode 8711cf1a48 remove redundant isVerbNonIndependent and consolidate test lanes
- drop always-true isVerbNonIndependent export; collapse shouldMerge branch
- remove node-compat exclusion list from bun-src-full lane
- simplify test:fast (drop test:runtime:compat), test:env, test:smoke:dist scripts
- rename test:launcher:env:src -> test:env in docs
2026-07-14 19:57:50 -07:00
85 changed files with 4870 additions and 3296 deletions
+1 -2
View File
@@ -213,7 +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)
subminer -H # browse history, then previous / replay / next / select / quit
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
@@ -238,7 +238,6 @@ SubMiner builds on the work of these open-source projects:
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [TsukiHime](https://tsukihime.org) | Release-track subtitle search and downloads (Animetosho successor) |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
+8 -5
View File
@@ -36,12 +36,15 @@
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.8.2",
"brace-expansion": "5.0.7",
"electron-builder-squirrel-windows": "26.8.2",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"picomatch": "4.0.4",
"tar": "7.5.16",
"tar": "7.5.21",
"tmp": "0.2.7",
},
"packages": {
@@ -273,7 +276,7 @@
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
@@ -515,7 +518,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -537,7 +540,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -765,7 +768,7 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
"tar": ["tar@7.5.21", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA=="],
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Prevented video startup from crashing when another process already owns the configured AnkiConnect proxy port, and added a notification explaining how to resolve the conflict.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
area: app
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: on quit, the AppImage runtime unmounted the FUSE squashfs while Chromium utility children (notably the network service) were still shutting down, killing them with SIGBUS. Background launches (`--background`, used by the mpv plugin and the launcher) now run through a small supervisor that mounts the AppImage via `--appimage-mount`, executes `AppRun` from that mount, and releases the mount only after no process is still executing from it. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to restore the old direct launch.
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: the short-lived background bootstrap spawned a Chromium GPU child that outlived it (surviving `app.exit`) and died with SIGBUS at session end when the bootstrap's FUSE mount was finally released. The bootstrap now runs with the GPU in-process so it leaves no children behind, and the detached app's mount remains supervised until its Chromium children finish. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to disable the detached-app mount supervisor.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: launcher
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: stats
- Validated nested and legacy AnkiConnect settings after splitting the resolver, preserving valid modern overrides while warning and falling back for invalid primitive values.
- Hardened stats routes against malformed IDs and static paths, stalled AniList searches, word-mining media collisions, missing Yomitan bridges, and throwing timing observers.
+1 -1
View File
@@ -1,4 +1,4 @@
type: added
area: overlay
- Added a TsukiHime integration for downloading primary and secondary subtitles, mirroring the Jimaku flow: `Ctrl+Shift+T` (configurable via `shortcuts.openTsukihime`) opens an in-overlay modal with a Japanese primary tab and a secondary tab that follows `secondarySub.secondarySubLanguages`. It parses the current video filename, searches TsukiHime releases, lists extracted text subtitle tracks filtered by the active tab, then downloads the chosen track, decompresses it (requires the `xz` binary), saves it next to the video with a language suffix (`<video>.en.<ext>`, `.ja` for Japanese tracks, etc.), and loads Japanese into mpv's primary slot or configured secondary tracks into its secondary slot. TsukiHime carries the Animetosho index and mirrors its attachment storage, so older releases stay reachable. No API key is required; also reachable via `subminer --open-tsukihime`, the `__tsukihime-open` keybinding command, and configurable under a new `tsukihime` config section.
- Added TsukiHime subtitle downloads for the current video, with Japanese primary and configured secondary-language tracks loaded directly into mpv.
+1 -1
View File
@@ -135,7 +135,7 @@ Focused commands:
bun run test:config # Source-level config schema/validation tests
bun run test:launcher # Launcher regression tests (config discovery + command routing)
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
bun run test:env # Launcher smoke + Lua plugin gate
bun run test:src # Bun-managed maintained src/** discovery lane
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
bun run test:scripts # Bun-managed scripts/** test lane
+7 -3
View File
@@ -73,9 +73,13 @@ 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
- **Previous episode**: plays the episode before the last watched one and continues into the previous season directory when the season starts
- **Replay last watched**: replays the most recently watched episode
- **Next episode**: plays the episode after the last watched one and 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 appears first
- **Quit SubMiner**: closes the history session without starting an episode
After an episode ends or you close mpv, the launcher returns to an action menu for the same series. The menu lists Previous, Rewatch, Next, Select episode, and Quit SubMiner in that order, omitting Previous or Next when no episode exists in that direction. Choosing Previous or Next can move between season directories. After you play another episode, Previous, Rewatch, and Next use it instead of the older database entry. Pressing Escape closes the history session.
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.
+1 -1
View File
@@ -93,7 +93,7 @@ subminer --start video.mkv # Explicit overlay start (use when mpv.autoSta
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
subminer https://youtu.be/... # Play a YouTube URL
subminer ytsearch:"jp news" # Play first YouTube search result
subminer -H # Browse watch history (replay/continue episodes, fzf or rofi picker)
subminer -H # Browse history, then choose previous/replay/next after playback
subminer app --setup # Open first-run setup popup
subminer --version # Print the launcher's version
subminer -v # Same as above
+2
View File
@@ -31,6 +31,8 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
- `src/config/` owns config definitions, defaults, loading, and resolution.
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
- `src/main/runtime/composers/` owns larger domain compositions.
- `src/main.ts` call sites invoke configured runtime handlers and runtime-object methods directly;
do not add local pass-through wrappers around them.
## Architecture Intent
+7 -3
View File
@@ -3,7 +3,7 @@
# Domain Ownership
Status: active
Last verified: 2026-05-23
Last verified: 2026-07-15
Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface
@@ -16,7 +16,9 @@ Read when: you need to find the owner module for a behavior or test surface
## Product / Integration Domains
- Config system: `src/config/`
- Config system: `src/config/`; Anki resolution is composed by
`src/config/resolve/anki-connect.ts` from focused resolvers in
`src/config/resolve/anki-connect/`
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
- MPV runtime and protocol: `src/core/services/mpv*.ts`
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
@@ -26,7 +28,9 @@ Read when: you need to find the owner module for a behavior or test surface
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Window trackers: `src/window-trackers/`
- Stats app: `stats/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/`
- Stats SPA: `stats/`
- Public docs site: `docs-site/`
## Shared Contract Entry Points
+1 -1
View File
@@ -17,7 +17,7 @@ Read when: selecting the right verification lane for a change
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
`--single-process` restores the shared-process mode for debugging.
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
unit, `scripts/**`, and the compiled runtime-compat slice.
unit, and `scripts/**`.
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
pull requests, stable tags, and prerelease tags. Keep common quality steps
there instead of copying them into caller workflows.
+193 -15
View File
@@ -7,11 +7,13 @@ import {
collectVideos,
findRofiTheme,
formatPickerLaunchError,
formatRofiPrompt,
showFzfMenu,
showRofiMenu,
} from '../picker.js';
import {
findNextEpisode,
findPreviousEpisode,
groupHistoryBySeries,
listSeasonDirs,
materializeCoverArt,
@@ -23,6 +25,167 @@ import {
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
export interface HistoryPlaybackSelection {
entry: HistorySeriesEntry;
videoPath: string;
themePath?: string | null;
entryIcon?: string | null;
}
interface HistorySessionMenuAction {
kind: HistorySessionAction;
label: string;
}
export function buildHistorySessionActions(
justPlayedPath: string,
previousEpisodePath: string | null,
nextEpisodePath: string | null,
): HistorySessionMenuAction[] {
const actions: HistorySessionMenuAction[] = [];
if (previousEpisodePath) {
actions.push({
kind: 'previous',
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
});
}
actions.push({
kind: 'replay',
label: `Rewatch episode: ${path.basename(justPlayedPath)}`,
});
if (nextEpisodePath) {
actions.push({
kind: 'next',
label: `Play next episode: ${path.basename(nextEpisodePath)}`,
});
}
actions.push(
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
);
return actions;
}
export function buildHistoryEntryActions(
lastWatchedPath: string | null,
previousEpisodePath: string | null,
nextEpisodePath: string | null,
): HistorySessionMenuAction[] {
const actions: HistorySessionMenuAction[] = [];
if (previousEpisodePath) {
actions.push({
kind: 'previous',
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
});
}
if (lastWatchedPath) {
actions.push({
kind: 'replay',
label: `Replay last watched: ${path.basename(lastWatchedPath)}`,
});
}
if (nextEpisodePath) {
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisodePath)}` });
}
actions.push(
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
);
return actions;
}
interface HistoryPlaybackLoopDeps {
play: (videoPath: string) => Promise<void>;
pickPostPlaybackAction: (input: {
entry: HistorySeriesEntry;
justPlayedPath: string;
previousEpisodePath: string | null;
nextEpisodePath: string | null;
}) => Promise<HistorySessionAction | null>;
findPreviousEpisode: (videoPath: string) => string | null;
findNextEpisode: (videoPath: string) => string | null;
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
}
export async function runHistoryPlaybackLoop(
initial: HistoryPlaybackSelection,
deps: HistoryPlaybackLoopDeps,
): Promise<void> {
let videoPath = initial.videoPath;
while (true) {
await deps.play(videoPath);
const previousEpisodePath = deps.findPreviousEpisode(videoPath);
const nextEpisodePath = deps.findNextEpisode(videoPath);
const action = await deps.pickPostPlaybackAction({
entry: initial.entry,
justPlayedPath: videoPath,
previousEpisodePath,
nextEpisodePath,
});
switch (action) {
case 'replay':
break;
case 'previous':
if (!previousEpisodePath) return;
videoPath = previousEpisodePath;
break;
case 'next':
if (!nextEpisodePath) return;
videoPath = nextEpisodePath;
break;
case 'browse': {
const browsedPath = await deps.browseEpisodes(initial.entry);
if (!browsedPath) return;
videoPath = browsedPath;
break;
}
case 'quit':
case null:
return;
}
}
}
export async function runHistorySession(
context: LauncherCommandContext,
play: (videoPath: string) => Promise<void>,
): Promise<boolean> {
const initial = await runHistoryCommand(context);
if (!initial) return false;
await runHistoryPlaybackLoop(initial, {
play,
findPreviousEpisode,
findNextEpisode,
browseEpisodes: async (entry) => browseEpisodes(entry, context, initial.themePath ?? null),
pickPostPlaybackAction: async ({
entry,
justPlayedPath,
previousEpisodePath,
nextEpisodePath,
}) => {
const actions = buildHistorySessionActions(
justPlayedPath,
previousEpisodePath,
nextEpisodePath,
);
const actionIdx = pickIndex(
actions.map((action) => action.label),
entry.displayName,
context.args.useRofi,
initial.themePath ?? null,
actions.map(() => initial.entryIcon ?? null),
);
return actionIdx < 0 ? null : actions[actionIdx]!.kind;
},
});
return true;
}
function checkPickerDependencies(args: Args): void {
if (args.useRofi) {
if (!commandExists('rofi')) fail('Missing dependency: rofi');
@@ -37,7 +200,16 @@ function showRofiIndexMenu(
themePath: string | null,
icons: Array<string | null> = [],
): number {
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
const rofiArgs = [
'-dmenu',
'-i',
'-matching',
'fuzzy',
'-format',
'i',
'-p',
formatRofiPrompt(prompt),
];
const hasIcons = icons.some(Boolean);
if (hasIcons) rofiArgs.push('-show-icons');
if (themePath) {
@@ -142,7 +314,7 @@ function browseEpisodes(
if (seasons.length > 1) {
const idx = pickIndex(
seasons.map((season) => season.name),
`${entry.displayName} Season`,
`${entry.displayName}: Season`,
args.useRofi,
themePath,
);
@@ -155,7 +327,9 @@ function browseEpisodes(
return pickEpisodeFromDir(dir, context);
}
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
export async function runHistoryCommand(
context: LauncherCommandContext,
): Promise<HistoryPlaybackSelection | null> {
const { args, scriptPath } = context;
checkPickerDependencies(args);
@@ -196,16 +370,14 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
const lastPath = path.resolve(entry.lastWatched.sourcePath);
const lastExists = fs.existsSync(lastPath);
const previousEpisode = findPreviousEpisode(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 actions = buildHistoryEntryActions(
lastExists ? lastPath : null,
previousEpisode,
nextEpisode,
);
const entryIcon = seriesIcons[seriesIdx] ?? null;
const actionIdx = pickIndex(
@@ -219,10 +391,16 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
switch (actions[actionIdx]!.kind) {
case 'replay':
return lastPath;
return { entry, videoPath: lastPath, themePath, entryIcon };
case 'previous':
return previousEpisode ? { entry, videoPath: previousEpisode, themePath, entryIcon } : null;
case 'next':
return nextEpisode;
case 'browse':
return browseEpisodes(entry, context, themePath);
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
case 'browse': {
const videoPath = browseEpisodes(entry, context, themePath);
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
}
case 'quit':
return null;
}
}
+294
View File
@@ -0,0 +1,294 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import {
buildHistoryEntryActions,
buildHistorySessionActions,
runHistoryPlaybackLoop,
} from './history-command.js';
import type { HistorySeriesEntry } from '../history.js';
type HistoryLoop = (
initial: { entry: HistorySeriesEntry; videoPath: string },
deps: {
play: (videoPath: string) => Promise<void>;
pickPostPlaybackAction: (input: {
entry: HistorySeriesEntry;
justPlayedPath: string;
previousEpisodePath: string | null;
nextEpisodePath: string | null;
}) => Promise<'previous' | 'replay' | 'next' | 'browse' | 'quit' | null>;
findPreviousEpisode: (videoPath: string) => string | null;
findNextEpisode: (videoPath: string) => string | null;
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
},
) => Promise<void>;
const typedRunHistoryPlaybackLoop: HistoryLoop = runHistoryPlaybackLoop;
function makeEntry(lastWatchedPath: string): HistorySeriesEntry {
return {
seriesRoot: path.dirname(lastWatchedPath),
displayName: 'Test Show',
coverBlobHash: null,
lastWatched: {
videoId: 1,
sourcePath: lastWatchedPath,
parsedTitle: 'Test Show',
parsedSeason: 1,
parsedEpisode: 1,
animeTitle: 'Test Show',
lastWatchedMs: 1,
coverBlobHash: null,
},
};
}
test('history loop plays an initial selection, browsed selection, then quits', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/episode-01.mkv');
const played: string[] = [];
const menuEntries: HistorySeriesEntry[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-02.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ entry: menuEntry }) => {
menuEntries.push(menuEntry);
return menuCount++ === 0 ? 'browse' : 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: () => null,
browseEpisodes: async (browseEntry) => {
assert.equal(browseEntry, entry);
return '/shows/test-show/episode-04.mkv';
},
},
);
assert.deepEqual(played, ['/shows/test-show/episode-02.mkv', '/shows/test-show/episode-04.mkv']);
assert.deepEqual(menuEntries, [entry, entry]);
});
test('history replay uses the actual just-played path instead of the database row', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
const played: string[] = [];
const menuPaths: string[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ justPlayedPath }) => {
menuPaths.push(justPlayedPath);
return menuCount++ === 0 ? 'replay' : 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: () => '/shows/test-show/episode-08.mkv',
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-07.mkv']);
assert.deepEqual(menuPaths, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-07.mkv',
]);
});
test('history next is computed from the actual just-played path', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
const played: string[] = [];
const nextInputs: string[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ nextEpisodePath }) => {
if (menuCount++ === 0) {
assert.equal(nextEpisodePath, '/shows/test-show/episode-08.mkv');
return 'next';
}
assert.equal(nextEpisodePath, null);
return 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: (videoPath) => {
nextInputs.push(videoPath);
return videoPath.endsWith('episode-07.mkv') ? '/shows/test-show/episode-08.mkv' : null;
},
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-08.mkv']);
assert.deepEqual(nextInputs, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-08.mkv',
]);
});
test('history show menu offers previous, rewatch, next, browse, and quit in order', () => {
assert.equal(
typeof buildHistorySessionActions,
'function',
'history session actions are not implemented',
);
assert.deepEqual(
buildHistorySessionActions(
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-06.mkv',
'/shows/test-show/episode-08.mkv',
),
[
{ kind: 'previous', label: 'Previous episode: episode-06.mkv' },
{ kind: 'replay', label: 'Rewatch episode: episode-07.mkv' },
{ kind: 'next', label: 'Play next episode: episode-08.mkv' },
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
],
);
});
test('history show menu omits previous and next when the just-played episode has neither', () => {
assert.equal(
typeof buildHistorySessionActions,
'function',
'history session actions are not implemented',
);
assert.deepEqual(buildHistorySessionActions('/shows/test-show/finale.mkv', null, null), [
{ kind: 'replay', label: 'Rewatch episode: finale.mkv' },
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
]);
});
test('history entry menu offers previous, replay, and next before playback starts', () => {
assert.equal(
typeof buildHistoryEntryActions,
'function',
'history entry actions not implemented',
);
assert.deepEqual(
buildHistoryEntryActions(
'/shows/test-show/episode-03.mkv',
'/shows/test-show/episode-02.mkv',
'/shows/test-show/episode-04.mkv',
),
[
{ kind: 'previous', label: 'Previous episode: episode-02.mkv' },
{ kind: 'replay', label: 'Replay last watched: episode-03.mkv' },
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
],
);
});
test('history entry menu omits replay when the last watched file is gone', () => {
assert.deepEqual(buildHistoryEntryActions(null, null, '/shows/test-show/episode-04.mkv'), [
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
]);
});
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-09.mkv');
const played: string[] = [];
const previousInputs: string[] = [];
const previousSeenByMenu: Array<string | null> = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
previousSeenByMenu.push(previousEpisodePath);
return menuCount++ === 0 ? 'previous' : 'quit';
},
findPreviousEpisode: (videoPath) => {
previousInputs.push(videoPath);
if (videoPath.endsWith('episode-07.mkv')) return '/shows/test-show/episode-06.mkv';
if (videoPath.endsWith('episode-06.mkv')) return '/shows/test-show/episode-05.mkv';
return null;
},
findNextEpisode: () => null,
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-06.mkv']);
assert.deepEqual(previousInputs, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-06.mkv',
]);
assert.deepEqual(previousSeenByMenu, [
'/shows/test-show/episode-06.mkv',
'/shows/test-show/episode-05.mkv',
]);
});
test('history playback loop stops advancing when previous is chosen with no prior episode', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/episode-01.mkv');
const played: string[] = [];
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-01.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
assert.equal(previousEpisodePath, null);
return 'previous';
},
findPreviousEpisode: () => null,
findNextEpisode: () => null,
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-01.mkv']);
});
+15 -1
View File
@@ -5,7 +5,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { LauncherCommandContext } from './context.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
function createContext(): LauncherCommandContext {
@@ -103,6 +103,20 @@ function createContext(): LauncherCommandContext {
};
}
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
const context = createContext();
const registeredSignals: NodeJS.Signals[] = [];
context.processAdapter.onSignal = (signal) => {
registeredSignals.push(signal);
};
registerCleanup(context);
registerCleanup(context);
assert.deepEqual(registeredSignals, ['SIGINT', 'SIGTERM']);
});
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
const calls: string[] = [];
const context = createContext();
+5 -1
View File
@@ -30,6 +30,7 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
const cleanupRegisteredProcessAdapters = new WeakSet<LauncherCommandContext['processAdapter']>();
function getLauncherConfigDir(): string {
return getDefaultConfigDir({
@@ -92,8 +93,10 @@ async function chooseTarget(
return { target: selected, kind: 'file' };
}
function registerCleanup(context: LauncherCommandContext): void {
export function registerCleanup(context: LauncherCommandContext): void {
const { args, processAdapter } = context;
if (cleanupRegisteredProcessAdapters.has(processAdapter)) return;
processAdapter.onSignal('SIGINT', () => {
stopOverlay(args);
processAdapter.exit(130);
@@ -102,6 +105,7 @@ function registerCleanup(context: LauncherCommandContext): void {
stopOverlay(args);
processAdapter.exit(143);
});
cleanupRegisteredProcessAdapters.add(processAdapter);
}
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
+43
View File
@@ -130,3 +130,46 @@ export function findNextEpisode(lastPath: string): string | null {
return findFirstEpisodeInNextSeason(resolvedLast, dir);
}
function findLastEpisodeInPreviousSeason(resolvedCurrent: string, dir: string): string | null {
const seriesRoot = resolveSeriesRoot(resolvedCurrent);
if (seriesRoot === dir) return null;
const seasons = listSeasonDirs(seriesRoot);
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
const currentSeason = seasonNumberFromDirName(path.basename(dir));
const previousSeasonEntry =
currentIdx >= 0
? seasons[currentIdx - 1]
: seasons
.filter(
(season) =>
currentSeason !== null && season.season !== null && season.season < currentSeason,
)
.at(-1);
if (!previousSeasonEntry) return null;
const previousSeason = sortVideosByEpisode(collectVideos(previousSeasonEntry.path, false));
return previousSeason.at(-1) ?? null;
}
export function findPreviousEpisode(currentPath: string): string | null {
const resolvedCurrent = path.resolve(currentPath);
const dir = path.dirname(resolvedCurrent);
const episodes = sortVideosByEpisode(collectVideos(dir, false));
const idx = episodes.indexOf(resolvedCurrent);
if (idx >= 0) {
if (idx - 1 >= 0) return episodes[idx - 1]!;
} else {
const currentInfo = parseMediaInfo(resolvedCurrent);
if (currentInfo.episode !== null) {
const candidates = episodes.filter((episode) => {
const info = parseMediaInfo(episode);
return info.episode !== null && info.episode < currentInfo.episode!;
});
const candidate = candidates[candidates.length - 1];
if (candidate) return candidate;
}
}
return findLastEpisodeInPreviousSeason(resolvedCurrent, dir);
}
+50
View File
@@ -7,6 +7,7 @@ import { Database } from 'bun:sqlite';
import {
detectImageExtension,
findNextEpisode,
findPreviousEpisode,
groupHistoryBySeries,
isReadonlyWalRetryError,
listSeasonDirs,
@@ -198,6 +199,55 @@ test('findNextEpisode advances seasons when a deleted file was the last episode'
}
});
test('findPreviousEpisode steps back within a season and across seasons', () => {
assert.equal(typeof findPreviousEpisode, 'function', 'findPreviousEpisode is not implemented');
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
assert.equal(
findPreviousEpisode(path.join(season1, 'Show - S01E03.mkv')),
path.join(season1, 'Show - S01E02.mkv'),
);
assert.equal(
findPreviousEpisode(path.join(season1, 'Show - S01E02.mkv')),
path.join(season1, 'Show - S01E01.mkv'),
);
assert.equal(findPreviousEpisode(path.join(season1, 'Show - S01E01.mkv')), null);
assert.equal(
findPreviousEpisode(path.join(season2, 'Show - S02E01.mkv')),
path.join(season1, 'Show - S01E03.mkv'),
);
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findPreviousEpisode 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(findPreviousEpisode(missing), path.join(season1, 'Show - S01E01.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findPreviousEpisode falls back to prior season when a deleted file was the first episode', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
fs.rmSync(path.join(season2, 'Show - S02E01.mkv'));
const missing = path.join(season2, 'Show - S02E01 - Deleted Cut.mkv');
assert.equal(findPreviousEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
function createHistoryDb(
+8 -6
View File
@@ -21,7 +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 { runHistorySession } from './commands/history-command.js';
import { runSyncCommand } from './commands/sync-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
@@ -149,13 +149,15 @@ async function main(): Promise<void> {
}
if (appContext.args.history) {
const selected = await runHistoryCommand(appContext);
if (!selected) {
const played = await runHistorySession(appContext, async (videoPath) => {
appContext.args.target = videoPath;
appContext.args.targetKind = 'file';
await runPlaybackCommand(appContext);
});
if (!played) {
log('info', args.logLevel, 'No watch history selection made, exiting');
return;
}
appContext.args.target = selected;
appContext.args.targetKind = 'file';
return;
}
await runPlaybackCommand(appContext);
+16 -1
View File
@@ -3,7 +3,22 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { findRofiTheme } from './picker';
import { findRofiTheme, formatRofiPrompt } from './picker';
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
test('formatRofiPrompt appends a single trailing space', () => {
assert.equal(formatRofiPrompt('Select Video'), 'Select Video ');
});
test('formatRofiPrompt collapses existing trailing whitespace to one space', () => {
assert.equal(formatRofiPrompt('Watch History '), 'Watch History ');
});
test('formatRofiPrompt leaves an empty prompt empty', () => {
assert.equal(formatRofiPrompt(''), '');
assert.equal(formatRofiPrompt(' '), '');
});
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
+13 -4
View File
@@ -17,13 +17,22 @@ export function escapeShellSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* Rofi renders the prompt flush against the input field, so keep exactly one
* trailing space to separate them.
*/
export function formatRofiPrompt(prompt: string): string {
const trimmed = prompt.trimEnd();
return trimmed ? `${trimmed} ` : '';
}
export function showRofiFlatMenu(
items: string[],
prompt: string,
initialQuery = '',
themePath: string | null = null,
): string {
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', formatRofiPrompt(prompt)];
if (themePath) {
args.push('-theme', themePath);
} else {
@@ -110,7 +119,7 @@ export async function promptOptionalJellyfinSearch(
themePath: string | null = null,
): Promise<string> {
if (useRofi && commandExists('rofi')) {
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
if (themePath) {
rofiArgs.push('-theme', themePath);
} else {
@@ -157,7 +166,7 @@ function showRofiIconMenu(
themePath: string | null = null,
): number {
if (entries.length === 0) return -1;
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', formatRofiPrompt(prompt)];
if (initialQuery) rofiArgs.push('-filter', initialQuery);
if (themePath) {
rofiArgs.push('-theme', themePath);
@@ -391,7 +400,7 @@ export function showRofiMenu(
'-dmenu',
'-i',
'-p',
'Select Video ',
formatRofiPrompt('Select Video'),
'-show-icons',
'-theme-str',
'configuration { font: "Noto Sans CJK JP Regular 8";}',
+8 -4
View File
@@ -632,8 +632,10 @@ test('adopted word frequency excludes active-session counts that merge later', (
// Only the ended session's count is adopted; the active session's slice
// is re-added when that session finalizes and syncs.
assert.equal(
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
?.frequency,
queryOne<{ frequency: number }>(
localPath,
`SELECT frequency FROM imm_words WHERE word = '食べた'`,
)?.frequency,
1,
);
@@ -650,8 +652,10 @@ test('adopted word frequency excludes active-session counts that merge later', (
assert.equal(second.sessionsAlreadyPresent, 1);
// 1 (ended session) + 4 (finalized session), not 5 + 4 = 9.
assert.equal(
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
?.frequency,
queryOne<{ frequency: number }>(
localPath,
`SELECT frequency FROM imm_words WHERE word = '食べた'`,
)?.frequency,
5,
);
} finally {
+11 -14
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.0-beta.1",
"version": "0.19.0-beta.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -47,13 +47,9 @@
"docs:preview": "bun run --cwd docs-site docs:preview",
"docs:test": "bun run --cwd docs-site test",
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
"test:config:src": "bun scripts/run-test-lane.mjs config",
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
@@ -64,15 +60,13 @@
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
"test:stats": "bun scripts/run-test-lane.mjs stats",
"test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src",
"test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src",
"test:runtime:compat": "bun run tsc && bun test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js",
"test:node:compat": "bun run test:runtime:compat",
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
"test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full",
"test": "bun run test:fast",
"test:config": "bun run test:config:src",
"test:launcher": "bun run test:launcher:src",
"test:config": "bun scripts/run-test-lane.mjs config",
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
"test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:src && bun run test:launcher:unit:src && bun run test:scripts && bun run test:runtime:compat",
"test:fast": "bun run test:src && bun run test:launcher:unit:src && bun run test:scripts",
"generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start",
@@ -89,12 +83,15 @@
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.8.2",
"brace-expansion": "5.0.7",
"electron-builder-squirrel-windows": "26.8.2",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"picomatch": "4.0.4",
"tar": "7.5.16",
"tar": "7.5.21",
"tmp": "0.2.7"
},
"keywords": [
+30 -9
View File
@@ -6,29 +6,50 @@
### Added
- **Sync Stats & History**
- Keep mining stats and watch history in sync across machines over SSH, from a new **Sync Stats & History** window (tray menu) or the `subminer sync <host>` command.
- Syncing merges data safely, so nothing is duplicated even if you sync the same machines repeatedly, and hosts with auto-sync enabled sync in the background on a schedule, reporting results as overlay notifications.
- Manual database snapshots (create, merge, reveal, delete) cover one-off transfers, and Windows machines running the built-in OpenSSH Server can be used as sync remotes too. No setup beyond SSH access is required on the remote side.
- New **Sync Stats & History** window (tray menu) and `subminer sync <host>` command keep mining stats and watch history in sync between machines over SSH, with saved devices, per-host sync direction, and live stage-by-stage progress.
- Merges are safe to repeat: data combines without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
- Manual snapshot tools (create, merge, reveal, delete) and connection testing cover one-off transfers; Windows machines running the built-in OpenSSH Server work as sync remotes too, with no setup needed beyond SSH access. Power users can script transfers directly with `--push`/`--pull`, `--check`, `--snapshot`/`--merge`, and `--json` flags.
- **TsukiHime Subtitle Downloads**
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow: `Ctrl+Shift+T` opens an in-overlay search modal with separate tabs for the primary and secondary languages.
- Matching releases are found automatically from the video filename; the chosen subtitle downloads and loads straight into mpv, no API key required.
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow.
- Press `Ctrl+Shift+T` to search by tabs for the primary and secondary languages; the matching release is found automatically from the video filename and loads straight into mpv, no API key required.
- **Post-Playback History Menu**
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
### Changed
- **Clipboard-Video Shortcut**: The "append clipboard video to queue" shortcut is now configurable (`shortcuts.appendClipboardVideoToQueue`) instead of fixed.
- **Clipboard-Video Shortcut**
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of being fixed.
### Fixed
- **Word Highlighting Accuracy**: Fixed several cases of incorrect word highlighting and annotations, including inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary being skipped for next-level ("N+1") highlighting.
- **Startup Playback Pausing Too Early**: Fixed playback resuming before subtitle processing had finished warming up, which could briefly show untranslated subtitles right after opening a video, most noticeable when resuming mid-episode.
- **Linux AppImage Crash Notification on Quit**: Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- **Word Highlighting Accuracy**
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
- **Startup Playback Pausing Too Early**
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
- **Linux AppImage Crash Notification on Quit**
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- If needed, the mount-keepalive behavior behind this fix can be disabled with `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1`.
- **AnkiConnect Proxy Port Conflict**
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
- **Stats & Settings Reliability**
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
## What's Changed
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
## Installation
-9
View File
@@ -16,15 +16,6 @@ export const testLanes: Record<string, TestLane> = {
'bun-src-full': {
roots: ['src'],
include: ['.test.ts', '.type-test.ts'],
// Node-compat suites; their dist builds run via test:runtime:compat.
exclude: [
'src/core/services/anki-jimaku-ipc.test.ts',
'src/core/services/ipc.test.ts',
'src/core/services/overlay-manager.test.ts',
'src/main/config-validation.test.ts',
'src/main/runtime/registry.test.ts',
'src/main/runtime/startup-config.test.ts',
],
},
config: {
roots: ['src/config'],
+62
View File
@@ -1,5 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -465,6 +467,66 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis
assert.equal(privateState.runtime.proxyServer, null);
});
test('AnkiIntegration reports an occupied proxy address through its notification seam', async () => {
const occupiedServer = http.createServer();
occupiedServer.listen(0, '127.0.0.1');
await once(occupiedServer, 'listening');
const occupiedAddress = occupiedServer.address();
assert.ok(occupiedAddress && typeof occupiedAddress === 'object');
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-proxy-collision-'));
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const integration = new AnkiIntegration(
{
enabled: true,
url: 'http://127.0.0.1:8765',
proxy: {
enabled: true,
host: '127.0.0.1',
port: occupiedAddress.port,
upstreamUrl: 'http://127.0.0.1:8765',
},
behavior: {
notificationType: 'overlay',
},
knownWords: {
highlightEnabled: false,
},
nPlusOne: {
enabled: false,
},
} as never,
{} as never,
{} as never,
undefined,
undefined,
undefined,
path.join(stateDir, 'known-words-cache.json'),
{},
undefined,
(payload) => {
overlayNotifications.push(payload as TestOverlayNotificationPayload);
},
);
try {
integration.start();
await integration.waitUntilReady();
assert.deepEqual(overlayNotifications, [
{
title: 'SubMiner',
body: `AnkiConnect proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`,
variant: 'info',
},
]);
} finally {
integration.stop();
occupiedServer.close();
await once(occupiedServer, 'close');
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
const integration = new AnkiIntegration(
{
+1
View File
@@ -461,6 +461,7 @@ export class AnkiIntegration {
getDeck: () => this.config.deck,
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[],
notifyUnavailable: (message) => this.showStatusNotification(message),
logInfo: (message, ...args) => log.info(message, ...args),
logWarn: (message, ...args) => log.warn(message, ...args),
logError: (message, ...args) => log.error(message, ...args),
@@ -543,3 +543,41 @@ test('proxy detects self-referential loop configuration', () => {
assert.equal(result, true);
});
test('proxy continues without a local listener when its address is already bound', async () => {
const occupiedServer = http.createServer();
occupiedServer.listen(0, '127.0.0.1');
await once(occupiedServer, 'listening');
const occupiedAddress = occupiedServer.address();
assert.ok(occupiedAddress && typeof occupiedAddress === 'object');
const info: string[] = [];
const warnings: string[] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async () => undefined,
logInfo: (message) => info.push(message),
logWarn: (message, ...args) => warnings.push([message, ...args].join(' ')),
logError: () => undefined,
});
try {
proxy.start({
host: '127.0.0.1',
port: occupiedAddress.port,
upstreamUrl: 'http://127.0.0.1:8765',
});
await proxy.waitUntilReady();
assert.equal(proxy.isRunning, false);
assert.deepEqual(warnings, [
`[anki-proxy] Local proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use; continuing without it. Change ankiConnect.proxy.port or stop the process using that address.`,
]);
proxy.stop();
assert.deepEqual(info, []);
} finally {
proxy.stop();
occupiedServer.close();
await once(occupiedServer, 'close');
}
});
@@ -27,6 +27,7 @@ export interface AnkiConnectProxyServerDeps {
logInfo: (message: string, ...args: unknown[]) => void;
logWarn: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void;
notifyUnavailable?: (message: string) => void;
}
export class AnkiConnectProxyServer {
@@ -78,7 +79,23 @@ export class AnkiConnectProxyServer {
void this.handleRequest(req, res, options.upstreamUrl);
});
const server = this.server;
this.server.on('error', (error) => {
if ((error as NodeJS.ErrnoException).code === 'EADDRINUSE') {
this.resolveReady?.();
this.resolveReady = null;
this.rejectReady = null;
if (this.server === server) {
this.server = null;
}
this.deps.logWarn(
`[anki-proxy] Local proxy unavailable because http://${options.host}:${options.port} is already in use; continuing without it. Change ankiConnect.proxy.port or stop the process using that address.`,
);
this.deps.notifyUnavailable?.(
`AnkiConnect proxy unavailable because http://${options.host}:${options.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`,
);
return;
}
this.rejectReady?.(error as Error);
this.resolveReady = null;
this.rejectReady = null;
+14 -2
View File
@@ -112,8 +112,20 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
if (!key) continue;
const valueNode = propertyValue(property);
const value = valueNode ? getNodeValue(valueNode) : undefined;
if (key === 'enabled' || key === 'minSentenceWords') {
canonicalNPlusOneValues.set(key, value);
if (key === 'enabled') {
if (typeof value === 'boolean') {
canonicalNPlusOneValues.set(key, value);
} else {
canonicalNPlusOneValues.delete(key);
}
continue;
}
if (key === 'minSentenceWords') {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
canonicalNPlusOneValues.set(key, value);
} else {
canonicalNPlusOneValues.delete(key);
}
continue;
}
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
+31
View File
@@ -2462,6 +2462,37 @@ test('resolves duplicate ankiConnect nPlusOne objects without rewriting config',
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('later invalid duplicate nPlusOne values supersede earlier valid values', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"enabled": true,
"minSentenceWords": 4
},
"nPlusOne": {
"enabled": "yes",
"minSentenceWords": "4"
}
}
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled);
assert.equal(
config.ankiConnect.nPlusOne.minSentenceWords,
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.enabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords'));
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -404,8 +404,7 @@ export function buildIntegrationConfigOptionRegistry(
path: 'tsukihime.apiBaseUrl',
kind: 'string',
defaultValue: defaultConfig.tsukihime.apiBaseUrl,
description:
'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
description: 'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
},
{
path: 'tsukihime.maxSearchResults',
+119
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions';
import { createWarningCollector } from '../warnings';
import { applyAnkiConnectResolution } from './anki-connect';
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
import type { ResolveContext } from './context';
function makeContext(ankiConnect: unknown): {
@@ -39,6 +40,90 @@ test('modern invalid knownWords.highlightEnabled warns modern key and does not f
);
});
test('invalid modern known-words primitive values warn and keep defaults', () => {
const { context, warnings } = makeContext({
knownWords: {
refreshMinutes: 'daily',
matchMode: false,
},
nPlusOne: {
minSentenceWords: 'three',
},
});
applyAnkiConnectResolution(context);
assert.equal(
context.resolved.ankiConnect.knownWords.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
);
assert.equal(
context.resolved.ankiConnect.knownWords.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
);
assert.equal(
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.knownWords.matchMode',
],
);
});
test('invalid legacy known-words primitive values warn and keep defaults', () => {
const { context, warnings } = makeContext({
behavior: {
nPlusOneHighlightEnabled: 'yes',
nPlusOneRefreshMinutes: 'daily',
nPlusOneMatchMode: false,
},
});
applyAnkiConnectResolution(context);
assert.equal(
context.resolved.ankiConnect.knownWords.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
);
assert.equal(
context.resolved.ankiConnect.knownWords.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
);
assert.equal(
context.resolved.ankiConnect.knownWords.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'ankiConnect.behavior.nPlusOneHighlightEnabled',
'ankiConnect.behavior.nPlusOneRefreshMinutes',
'ankiConnect.behavior.nPlusOneMatchMode',
],
);
});
test('known-words resolution can run independently from other Anki domains', () => {
const { context, warnings } = makeContext({
knownWords: { highlightEnabled: true },
proxy: { port: -1 },
});
const ankiConnect = context.src.ankiConnect as Record<string, unknown>;
applyAnkiKnownWordsResolution(context, ankiConnect, {});
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(
warnings.some((warning) => warning.path.startsWith('ankiConnect.proxy')),
false,
);
});
test('normalizes ankiConnect tags by trimming and deduping', () => {
const { context, warnings } = makeContext({
tags: [' SubMiner ', 'Mining', 'SubMiner', ' Mining '],
@@ -177,6 +262,40 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
);
});
test('invalid modern Anki subtrees warn and keep resolved defaults', () => {
const { context, warnings } = makeContext({
fields: { word: 7 },
media: { generateAudio: 'yes' },
behavior: { overwriteAudio: 'yes' },
metadata: { pattern: false },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.fields.word, DEFAULT_CONFIG.ankiConnect.fields.word);
assert.equal(
context.resolved.ankiConnect.media.generateAudio,
DEFAULT_CONFIG.ankiConnect.media.generateAudio,
);
assert.equal(
context.resolved.ankiConnect.behavior.overwriteAudio,
DEFAULT_CONFIG.ankiConnect.behavior.overwriteAudio,
);
assert.equal(
context.resolved.ankiConnect.metadata.pattern,
DEFAULT_CONFIG.ankiConnect.metadata.pattern,
);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'ankiConnect.fields.word',
'ankiConnect.media.generateAudio',
'ankiConnect.behavior.overwriteAudio',
'ankiConnect.metadata.pattern',
],
);
});
test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => {
const { context, warnings } = makeContext({
wordField: 'TargetWordLegacy',
+16 -943
View File
@@ -1,952 +1,25 @@
import { DEFAULT_CONFIG } from '../definitions';
import type { ResolveContext } from './context';
import { isNotificationType, type NotificationType } from '../../types/notification';
import { asBoolean, asColor, asNumber, asString, isObject } from './shared';
function asNotificationType(value: unknown): NotificationType | undefined {
return isNotificationType(value) ? value : undefined;
}
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
import { applyAnkiKikuResolution } from './anki-connect/kiku';
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
import { applyAnkiModernResolution } from './anki-connect/modern';
import { isObject } from './shared';
export function applyAnkiConnectResolution(context: ResolveContext): void {
if (!isObject(context.src.ankiConnect)) {
return;
}
const ac = context.src.ankiConnect;
const behavior = isObject(ac.behavior) ? (ac.behavior as Record<string, unknown>) : {};
const fields = isObject(ac.fields) ? (ac.fields as Record<string, unknown>) : {};
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {};
const legacyKeys = new Set([
'wordField',
'audioField',
'imageField',
'sentenceField',
'miscInfoField',
'miscInfoPattern',
'generateAudio',
'generateImage',
'imageType',
'imageFormat',
'imageQuality',
'imageMaxWidth',
'imageMaxHeight',
'animatedFps',
'animatedMaxWidth',
'animatedMaxHeight',
'animatedCrf',
'syncAnimatedImageToWordAudio',
'audioPadding',
'fallbackDuration',
'maxMediaDuration',
'overwriteAudio',
'overwriteImage',
'mediaInsertMode',
'highlightWord',
'notificationType',
'autoUpdateNewCards',
]);
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, key);
const ankiConnect = context.src.ankiConnect;
const behavior = isObject(ankiConnect.behavior) ? ankiConnect.behavior : {};
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
const media = isObject(ankiConnect.media) ? ankiConnect.media : {};
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
const {
knownWords: _knownWordsConfigFromAnkiConnect,
nPlusOne: _nPlusOneConfigFromAnkiConnect,
ai: _ankiAiConfig,
...ankiConnectWithoutKnownWordsOrNPlusOne
} = ac as Record<string, unknown>;
const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !legacyKeys.has(key)),
);
context.resolved.ankiConnect = {
...context.resolved.ankiConnect,
...(isObject(ankiConnectWithoutLegacy)
? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>)
: {}),
fields: {
...context.resolved.ankiConnect.fields,
...(isObject(ac.fields)
? (ac.fields as (typeof context.resolved)['ankiConnect']['fields'])
: {}),
},
media: {
...context.resolved.ankiConnect.media,
...(isObject(ac.media)
? (ac.media as (typeof context.resolved)['ankiConnect']['media'])
: {}),
},
knownWords: {
...context.resolved.ankiConnect.knownWords,
},
behavior: {
...context.resolved.ankiConnect.behavior,
...(isObject(ac.behavior)
? (ac.behavior as (typeof context.resolved)['ankiConnect']['behavior'])
: {}),
},
proxy: {
...context.resolved.ankiConnect.proxy,
},
metadata: {
...context.resolved.ankiConnect.metadata,
...(isObject(ac.metadata)
? (ac.metadata as (typeof context.resolved)['ankiConnect']['metadata'])
: {}),
},
isLapis: {
...context.resolved.ankiConnect.isLapis,
},
isKiku: {
...context.resolved.ankiConnect.isKiku,
...(isObject(ac.isKiku)
? (ac.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
: {}),
},
};
if (hasOwn(media, 'mirrorMpvVolume')) {
const parsed = asBoolean(media.mirrorMpvVolume);
if (parsed === undefined) {
context.resolved.ankiConnect.media.mirrorMpvVolume =
DEFAULT_CONFIG.ankiConnect.media.mirrorMpvVolume;
context.warn(
'ankiConnect.media.mirrorMpvVolume',
media.mirrorMpvVolume,
context.resolved.ankiConnect.media.mirrorMpvVolume,
'Expected boolean.',
);
} else {
context.resolved.ankiConnect.media.mirrorMpvVolume = parsed;
}
}
if (hasOwn(behavior, 'notificationType')) {
const parsed = asNotificationType(behavior.notificationType);
if (parsed === undefined) {
context.resolved.ankiConnect.behavior.notificationType =
DEFAULT_CONFIG.ankiConnect.behavior.notificationType;
context.warn(
'ankiConnect.behavior.notificationType',
behavior.notificationType,
context.resolved.ankiConnect.behavior.notificationType,
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
);
} else {
context.resolved.ankiConnect.behavior.notificationType = parsed;
}
}
if (isObject(ac.isLapis)) {
const lapisEnabled = asBoolean(ac.isLapis.enabled);
if (lapisEnabled !== undefined) {
context.resolved.ankiConnect.isLapis.enabled = lapisEnabled;
} else if (ac.isLapis.enabled !== undefined) {
context.warn(
'ankiConnect.isLapis.enabled',
ac.isLapis.enabled,
context.resolved.ankiConnect.isLapis.enabled,
'Expected boolean.',
);
}
const sentenceCardModel = asString(ac.isLapis.sentenceCardModel);
if (sentenceCardModel !== undefined) {
context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel;
} else if (ac.isLapis.sentenceCardModel !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardModel',
ac.isLapis.sentenceCardModel,
context.resolved.ankiConnect.isLapis.sentenceCardModel,
'Expected string.',
);
}
if (ac.isLapis.sentenceCardSentenceField !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardSentenceField',
ac.isLapis.sentenceCardSentenceField,
'Sentence',
'Deprecated key; sentence-card sentence field is fixed to Sentence.',
);
}
if (ac.isLapis.sentenceCardAudioField !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardAudioField',
ac.isLapis.sentenceCardAudioField,
'SentenceAudio',
'Deprecated key; sentence-card audio field is fixed to SentenceAudio.',
);
}
} else if (ac.isLapis !== undefined) {
context.warn(
'ankiConnect.isLapis',
ac.isLapis,
context.resolved.ankiConnect.isLapis,
'Expected object.',
);
}
if (isObject(ac.proxy)) {
const proxyEnabled = asBoolean(proxy.enabled);
if (proxyEnabled !== undefined) {
context.resolved.ankiConnect.proxy.enabled = proxyEnabled;
} else if (proxy.enabled !== undefined) {
context.warn(
'ankiConnect.proxy.enabled',
proxy.enabled,
context.resolved.ankiConnect.proxy.enabled,
'Expected boolean.',
);
}
const proxyHost = asString(proxy.host);
if (proxyHost !== undefined && proxyHost.trim().length > 0) {
context.resolved.ankiConnect.proxy.host = proxyHost.trim();
} else if (proxy.host !== undefined) {
context.warn(
'ankiConnect.proxy.host',
proxy.host,
context.resolved.ankiConnect.proxy.host,
'Expected non-empty string.',
);
}
const proxyUpstreamUrl = asString(proxy.upstreamUrl);
if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) {
context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim();
} else if (proxy.upstreamUrl !== undefined) {
context.warn(
'ankiConnect.proxy.upstreamUrl',
proxy.upstreamUrl,
context.resolved.ankiConnect.proxy.upstreamUrl,
'Expected non-empty string.',
);
}
const proxyPort = asNumber(proxy.port);
if (
proxyPort !== undefined &&
Number.isInteger(proxyPort) &&
proxyPort >= 1 &&
proxyPort <= 65535
) {
context.resolved.ankiConnect.proxy.port = proxyPort;
} else if (proxy.port !== undefined) {
context.warn(
'ankiConnect.proxy.port',
proxy.port,
context.resolved.ankiConnect.proxy.port,
'Expected integer between 1 and 65535.',
);
}
} else if (ac.proxy !== undefined) {
context.warn(
'ankiConnect.proxy',
ac.proxy,
context.resolved.ankiConnect.proxy,
'Expected object.',
);
}
if (isObject(ac.ai)) {
const aiEnabled = asBoolean(ac.ai.enabled);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ac.ai.enabled !== undefined) {
context.warn(
'ankiConnect.ai.enabled',
ac.ai.enabled,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean.',
);
}
const aiModel = asString(ac.ai.model);
if (aiModel !== undefined) {
context.resolved.ankiConnect.ai.model = aiModel;
} else if (ac.ai.model !== undefined) {
context.warn(
'ankiConnect.ai.model',
ac.ai.model,
context.resolved.ankiConnect.ai.model,
'Expected string.',
);
}
const aiSystemPrompt = asString(ac.ai.systemPrompt);
if (aiSystemPrompt !== undefined) {
context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt;
} else if (ac.ai.systemPrompt !== undefined) {
context.warn(
'ankiConnect.ai.systemPrompt',
ac.ai.systemPrompt,
context.resolved.ankiConnect.ai.systemPrompt,
'Expected string.',
);
}
} else {
const aiEnabled = asBoolean(ac.ai);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ac.ai !== undefined) {
context.warn(
'ankiConnect.ai',
ac.ai,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean or object.',
);
}
}
if (Array.isArray(ac.tags)) {
const normalizedTags = ac.tags
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedTags.length === ac.tags.length) {
context.resolved.ankiConnect.tags = [...new Set(normalizedTags)];
} else {
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
context.warn(
'ankiConnect.tags',
ac.tags,
context.resolved.ankiConnect.tags,
'Expected an array of non-empty strings.',
);
}
} else if (ac.tags !== undefined) {
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
context.warn(
'ankiConnect.tags',
ac.tags,
context.resolved.ankiConnect.tags,
'Expected an array of strings.',
);
}
const legacy = ac as Record<string, unknown>;
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
return undefined;
}
return parsed;
};
const asPositiveInteger = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
};
const asPositiveNumber = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || parsed <= 0) {
return undefined;
}
return parsed;
};
const asNonNegativeNumber = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || parsed < 0) {
return undefined;
}
return parsed;
};
const asImageType = (value: unknown): 'static' | 'avif' | undefined => {
return value === 'static' || value === 'avif' ? value : undefined;
};
const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => {
return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined;
};
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
return value === 'append' || value === 'prepend' ? value : undefined;
};
const mapLegacy = <T>(
key: string,
parse: (value: unknown) => T | undefined,
apply: (value: T) => void,
fallback: unknown,
message: string,
): void => {
const value = legacy[key];
if (value === undefined) return;
const parsed = parse(value);
if (parsed === undefined) {
context.warn(`ankiConnect.${key}`, value, fallback, message);
return;
}
apply(parsed);
};
if (!hasOwn(fields, 'audio')) {
mapLegacy(
'audioField',
asString,
(value) => {
context.resolved.ankiConnect.fields.audio = value;
},
context.resolved.ankiConnect.fields.audio,
'Expected string.',
);
}
if (!hasOwn(fields, 'word')) {
mapLegacy(
'wordField',
asString,
(value) => {
context.resolved.ankiConnect.fields.word = value;
},
context.resolved.ankiConnect.fields.word,
'Expected string.',
);
}
if (!hasOwn(fields, 'image')) {
mapLegacy(
'imageField',
asString,
(value) => {
context.resolved.ankiConnect.fields.image = value;
},
context.resolved.ankiConnect.fields.image,
'Expected string.',
);
}
if (!hasOwn(fields, 'sentence')) {
mapLegacy(
'sentenceField',
asString,
(value) => {
context.resolved.ankiConnect.fields.sentence = value;
},
context.resolved.ankiConnect.fields.sentence,
'Expected string.',
);
}
if (!hasOwn(fields, 'miscInfo')) {
mapLegacy(
'miscInfoField',
asString,
(value) => {
context.resolved.ankiConnect.fields.miscInfo = value;
},
context.resolved.ankiConnect.fields.miscInfo,
'Expected string.',
);
}
if (!hasOwn(metadata, 'pattern')) {
mapLegacy(
'miscInfoPattern',
asString,
(value) => {
context.resolved.ankiConnect.metadata.pattern = value;
},
context.resolved.ankiConnect.metadata.pattern,
'Expected string.',
);
}
if (!hasOwn(media, 'generateAudio')) {
mapLegacy(
'generateAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.generateAudio = value;
},
context.resolved.ankiConnect.media.generateAudio,
'Expected boolean.',
);
}
if (!hasOwn(media, 'generateImage')) {
mapLegacy(
'generateImage',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.generateImage = value;
},
context.resolved.ankiConnect.media.generateImage,
'Expected boolean.',
);
}
if (!hasOwn(media, 'imageType')) {
mapLegacy(
'imageType',
asImageType,
(value) => {
context.resolved.ankiConnect.media.imageType = value;
},
context.resolved.ankiConnect.media.imageType,
"Expected 'static' or 'avif'.",
);
}
if (!hasOwn(media, 'imageFormat')) {
mapLegacy(
'imageFormat',
asImageFormat,
(value) => {
context.resolved.ankiConnect.media.imageFormat = value;
},
context.resolved.ankiConnect.media.imageFormat,
"Expected 'jpg', 'png', or 'webp'.",
);
}
if (!hasOwn(media, 'imageQuality')) {
mapLegacy(
'imageQuality',
(value) => asIntegerInRange(value, 1, 100),
(value) => {
context.resolved.ankiConnect.media.imageQuality = value;
},
context.resolved.ankiConnect.media.imageQuality,
'Expected integer between 1 and 100.',
);
}
if (!hasOwn(media, 'imageMaxWidth')) {
mapLegacy(
'imageMaxWidth',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.imageMaxWidth = value;
},
context.resolved.ankiConnect.media.imageMaxWidth,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'imageMaxHeight')) {
mapLegacy(
'imageMaxHeight',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.imageMaxHeight = value;
},
context.resolved.ankiConnect.media.imageMaxHeight,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedFps')) {
mapLegacy(
'animatedFps',
(value) => asIntegerInRange(value, 1, 60),
(value) => {
context.resolved.ankiConnect.media.animatedFps = value;
},
context.resolved.ankiConnect.media.animatedFps,
'Expected integer between 1 and 60.',
);
}
if (!hasOwn(media, 'animatedMaxWidth')) {
mapLegacy(
'animatedMaxWidth',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.animatedMaxWidth = value;
},
context.resolved.ankiConnect.media.animatedMaxWidth,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedMaxHeight')) {
mapLegacy(
'animatedMaxHeight',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.animatedMaxHeight = value;
},
context.resolved.ankiConnect.media.animatedMaxHeight,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedCrf')) {
mapLegacy(
'animatedCrf',
(value) => asIntegerInRange(value, 0, 63),
(value) => {
context.resolved.ankiConnect.media.animatedCrf = value;
},
context.resolved.ankiConnect.media.animatedCrf,
'Expected integer between 0 and 63.',
);
}
if (!hasOwn(media, 'syncAnimatedImageToWordAudio')) {
mapLegacy(
'syncAnimatedImageToWordAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio = value;
},
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio,
'Expected boolean.',
);
}
if (!hasOwn(media, 'audioPadding')) {
mapLegacy(
'audioPadding',
asNonNegativeNumber,
(value) => {
context.resolved.ankiConnect.media.audioPadding = value;
},
context.resolved.ankiConnect.media.audioPadding,
'Expected non-negative number.',
);
}
if (!hasOwn(media, 'fallbackDuration')) {
mapLegacy(
'fallbackDuration',
asPositiveNumber,
(value) => {
context.resolved.ankiConnect.media.fallbackDuration = value;
},
context.resolved.ankiConnect.media.fallbackDuration,
'Expected positive number.',
);
}
if (!hasOwn(media, 'maxMediaDuration')) {
mapLegacy(
'maxMediaDuration',
asNonNegativeNumber,
(value) => {
context.resolved.ankiConnect.media.maxMediaDuration = value;
},
context.resolved.ankiConnect.media.maxMediaDuration,
'Expected non-negative number.',
);
}
if (!hasOwn(behavior, 'overwriteAudio')) {
mapLegacy(
'overwriteAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.overwriteAudio = value;
},
context.resolved.ankiConnect.behavior.overwriteAudio,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'overwriteImage')) {
mapLegacy(
'overwriteImage',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.overwriteImage = value;
},
context.resolved.ankiConnect.behavior.overwriteImage,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'mediaInsertMode')) {
mapLegacy(
'mediaInsertMode',
asMediaInsertMode,
(value) => {
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
},
context.resolved.ankiConnect.behavior.mediaInsertMode,
"Expected 'append' or 'prepend'.",
);
}
if (!hasOwn(behavior, 'highlightWord')) {
mapLegacy(
'highlightWord',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.highlightWord = value;
},
context.resolved.ankiConnect.behavior.highlightWord,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'notificationType')) {
mapLegacy(
'notificationType',
asNotificationType,
(value) => {
context.resolved.ankiConnect.behavior.notificationType = value;
},
context.resolved.ankiConnect.behavior.notificationType,
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
);
}
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
mapLegacy(
'autoUpdateNewCards',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.autoUpdateNewCards = value;
},
context.resolved.ankiConnect.behavior.autoUpdateNewCards,
'Expected boolean.',
);
}
const knownWordsConfig = isObject(ac.knownWords)
? (ac.knownWords as Record<string, unknown>)
: {};
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
if (knownWordsHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
} else if (knownWordsConfig.highlightEnabled !== undefined) {
context.warn(
'ankiConnect.knownWords.highlightEnabled',
knownWordsConfig.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled =
legacyBehaviorNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.behavior.nPlusOneHighlightEnabled',
behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else {
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
}
}
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const hasValidKnownWordsRefreshMinutes =
knownWordsRefreshMinutes !== undefined &&
Number.isInteger(knownWordsRefreshMinutes) &&
knownWordsRefreshMinutes > 0;
if (knownWordsRefreshMinutes !== undefined) {
if (hasValidKnownWordsRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
} else {
context.warn(
'ankiConnect.knownWords.refreshMinutes',
knownWordsConfig.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const hasValidLegacyRefreshMinutes =
legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
legacyBehaviorNPlusOneRefreshMinutes > 0;
if (hasValidLegacyRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else {
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately);
if (knownWordsAddMinedWordsImmediately !== undefined) {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
knownWordsAddMinedWordsImmediately;
} else if (knownWordsConfig.addMinedWordsImmediately !== undefined) {
context.warn(
'ankiConnect.knownWords.addMinedWordsImmediately',
knownWordsConfig.addMinedWordsImmediately,
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
} else {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
}
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
if (nPlusOneEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
} else if (nPlusOneConfig.enabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.enabled',
nPlusOneConfig.enabled,
context.resolved.ankiConnect.nPlusOne.enabled,
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
}
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
const hasValidNPlusOneMinSentenceWords =
nPlusOneMinSentenceWords !== undefined &&
Number.isInteger(nPlusOneMinSentenceWords) &&
nPlusOneMinSentenceWords > 0;
if (nPlusOneMinSentenceWords !== undefined) {
if (hasValidNPlusOneMinSentenceWords) {
context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords;
} else {
context.warn(
'ankiConnect.nPlusOne.minSentenceWords',
nPlusOneConfig.minSentenceWords,
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
'Expected a positive integer.',
);
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
}
} else {
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
}
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidKnownWordsMatchMode =
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
const hasValidLegacyMatchMode =
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
if (hasValidKnownWordsMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
} else if (knownWordsMatchMode !== undefined) {
context.warn(
'ankiConnect.knownWords.matchMode',
knownWordsConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else {
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
const DEFAULT_FIELDS = [
DEFAULT_CONFIG.ankiConnect.fields.word,
'Word',
'Reading',
'Word Reading',
];
const knownWordsDecks = knownWordsConfig.decks;
if (isObject(knownWordsDecks)) {
const resolved: Record<string, string[]> = {};
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
const deckName = deck.trim();
if (!deckName) continue;
if (Array.isArray(fields) && fields.every((f) => typeof f === 'string')) {
resolved[deckName] = (fields as string[]).map((f) => f.trim()).filter((f) => f.length > 0);
} else {
context.warn(
`ankiConnect.knownWords.decks["${deckName}"]`,
fields,
DEFAULT_FIELDS,
'Expected an array of field name strings.',
);
resolved[deckName] = DEFAULT_FIELDS;
}
}
context.resolved.ankiConnect.knownWords.decks = resolved;
} else if (Array.isArray(knownWordsDecks)) {
const normalized = knownWordsDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
for (const deck of new Set(normalized)) {
resolved[deck] = DEFAULT_FIELDS;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
resolved,
'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }',
);
}
} else if (knownWordsDecks !== undefined) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
context.resolved.ankiConnect.knownWords.decks,
'Expected an object mapping deck names to field arrays.',
);
}
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
? (context.src.subtitleStyle as Record<string, unknown>)
: {};
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
const knownWordsColor = asColor(knownWordsConfig.color);
if (knownWordsColor !== undefined) {
if (!hasCanonicalKnownWordColor) {
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
}
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
);
} else if (knownWordsConfig.color !== undefined) {
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Expected a hex color value.',
);
}
if (
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isKiku.fieldGrouping',
context.resolved.ankiConnect.isKiku.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isKiku.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
}
initializeAnkiConnectResolution(context, ankiConnect);
applyAnkiModernResolution(context, ankiConnect, behavior, media);
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context);
}
+57
View File
@@ -0,0 +1,57 @@
import type { ResolveContext } from '../context';
import { asBoolean, asString, isObject } from '../shared';
export function applyAiResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
if (isObject(ankiConnect.ai)) {
const aiEnabled = asBoolean(ankiConnect.ai.enabled);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ankiConnect.ai.enabled !== undefined) {
context.warn(
'ankiConnect.ai.enabled',
ankiConnect.ai.enabled,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean.',
);
}
const aiModel = asString(ankiConnect.ai.model);
if (aiModel !== undefined) {
context.resolved.ankiConnect.ai.model = aiModel;
} else if (ankiConnect.ai.model !== undefined) {
context.warn(
'ankiConnect.ai.model',
ankiConnect.ai.model,
context.resolved.ankiConnect.ai.model,
'Expected string.',
);
}
const aiSystemPrompt = asString(ankiConnect.ai.systemPrompt);
if (aiSystemPrompt !== undefined) {
context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt;
} else if (ankiConnect.ai.systemPrompt !== undefined) {
context.warn(
'ankiConnect.ai.systemPrompt',
ankiConnect.ai.systemPrompt,
context.resolved.ankiConnect.ai.systemPrompt,
'Expected string.',
);
}
} else {
const aiEnabled = asBoolean(ankiConnect.ai);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ankiConnect.ai !== undefined) {
context.warn(
'ankiConnect.ai',
ankiConnect.ai,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean or object.',
);
}
}
}
@@ -0,0 +1,81 @@
import type { ResolveContext } from '../context';
import { isObject } from '../shared';
const LEGACY_KEYS = new Set([
'wordField',
'audioField',
'imageField',
'sentenceField',
'miscInfoField',
'miscInfoPattern',
'generateAudio',
'generateImage',
'imageType',
'imageFormat',
'imageQuality',
'imageMaxWidth',
'imageMaxHeight',
'animatedFps',
'animatedMaxWidth',
'animatedMaxHeight',
'animatedCrf',
'syncAnimatedImageToWordAudio',
'audioPadding',
'fallbackDuration',
'maxMediaDuration',
'overwriteAudio',
'overwriteImage',
'mediaInsertMode',
'highlightWord',
'notificationType',
'autoUpdateNewCards',
]);
export function initializeAnkiConnectResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
const {
knownWords: _knownWordsConfigFromAnkiConnect,
nPlusOne: _nPlusOneConfigFromAnkiConnect,
ai: _ankiAiConfig,
...ankiConnectWithoutKnownWordsOrNPlusOne
} = ankiConnect;
const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !LEGACY_KEYS.has(key)),
);
context.resolved.ankiConnect = {
...context.resolved.ankiConnect,
...(isObject(ankiConnectWithoutLegacy)
? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>)
: {}),
fields: {
...context.resolved.ankiConnect.fields,
},
media: {
...context.resolved.ankiConnect.media,
},
knownWords: {
...context.resolved.ankiConnect.knownWords,
},
behavior: {
...context.resolved.ankiConnect.behavior,
},
proxy: {
...context.resolved.ankiConnect.proxy,
},
metadata: {
...context.resolved.ankiConnect.metadata,
},
isLapis: {
...context.resolved.ankiConnect.isLapis,
},
isKiku: {
...context.resolved.ankiConnect.isKiku,
...(isObject(ankiConnect.isKiku)
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
: {}),
},
};
}
+19
View File
@@ -0,0 +1,19 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
export function applyAnkiKikuResolution(context: ResolveContext): void {
if (
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isKiku.fieldGrouping',
context.resolved.ankiConnect.isKiku.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isKiku.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
}
}
@@ -0,0 +1,267 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean, asColor, asNumber, asString, isObject } from '../shared';
import { hasOwn } from './shared';
export function applyAnkiKnownWordsResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
behavior: Record<string, unknown>,
): void {
const knownWordsConfig = isObject(ankiConnect.knownWords) ? ankiConnect.knownWords : {};
const nPlusOneConfig = isObject(ankiConnect.nPlusOne) ? ankiConnect.nPlusOne : {};
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
if (knownWordsHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
} else if (hasOwn(knownWordsConfig, 'highlightEnabled')) {
context.warn(
'ankiConnect.knownWords.highlightEnabled',
knownWordsConfig.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled =
legacyBehaviorNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.behavior.nPlusOneHighlightEnabled',
behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else if (hasOwn(behavior, 'nPlusOneHighlightEnabled')) {
context.warn(
'ankiConnect.behavior.nPlusOneHighlightEnabled',
behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
}
}
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const hasValidKnownWordsRefreshMinutes =
knownWordsRefreshMinutes !== undefined &&
Number.isInteger(knownWordsRefreshMinutes) &&
knownWordsRefreshMinutes > 0;
if (hasOwn(knownWordsConfig, 'refreshMinutes')) {
if (hasValidKnownWordsRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
} else {
context.warn(
'ankiConnect.knownWords.refreshMinutes',
knownWordsConfig.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (hasOwn(behavior, 'nPlusOneRefreshMinutes')) {
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const hasValidLegacyRefreshMinutes =
legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
legacyBehaviorNPlusOneRefreshMinutes > 0;
if (hasValidLegacyRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else {
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately);
if (knownWordsAddMinedWordsImmediately !== undefined) {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
knownWordsAddMinedWordsImmediately;
} else if (knownWordsConfig.addMinedWordsImmediately !== undefined) {
context.warn(
'ankiConnect.knownWords.addMinedWordsImmediately',
knownWordsConfig.addMinedWordsImmediately,
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
} else {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
}
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
if (nPlusOneEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
} else if (nPlusOneConfig.enabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.enabled',
nPlusOneConfig.enabled,
context.resolved.ankiConnect.nPlusOne.enabled,
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
}
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
const hasValidNPlusOneMinSentenceWords =
nPlusOneMinSentenceWords !== undefined &&
Number.isInteger(nPlusOneMinSentenceWords) &&
nPlusOneMinSentenceWords > 0;
if (hasOwn(nPlusOneConfig, 'minSentenceWords')) {
if (hasValidNPlusOneMinSentenceWords) {
context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords;
} else {
context.warn(
'ankiConnect.nPlusOne.minSentenceWords',
nPlusOneConfig.minSentenceWords,
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
'Expected a positive integer.',
);
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
}
} else {
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
}
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidKnownWordsMatchMode =
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
const hasValidLegacyMatchMode =
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
if (hasValidKnownWordsMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
} else if (hasOwn(knownWordsConfig, 'matchMode')) {
context.warn(
'ankiConnect.knownWords.matchMode',
knownWordsConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} else if (hasOwn(behavior, 'nPlusOneMatchMode')) {
if (hasValidLegacyMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else {
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
const knownWordsDecks = knownWordsConfig.decks;
if (isObject(knownWordsDecks)) {
const resolved: Record<string, string[]> = {};
for (const [deck, fields] of Object.entries(knownWordsDecks)) {
const deckName = deck.trim();
if (!deckName) continue;
if (Array.isArray(fields) && fields.every((field) => typeof field === 'string')) {
resolved[deckName] = fields
.map((field) => field.trim())
.filter((field) => field.length > 0);
} else {
context.warn(
`ankiConnect.knownWords.decks["${deckName}"]`,
fields,
defaultFields,
'Expected an array of field name strings.',
);
resolved[deckName] = defaultFields;
}
}
context.resolved.ankiConnect.knownWords.decks = resolved;
} else if (Array.isArray(knownWordsDecks)) {
const normalized = knownWordsDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
for (const deck of new Set(normalized)) {
resolved[deck] = defaultFields;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
resolved,
'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }',
);
}
} else if (knownWordsDecks !== undefined) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
context.resolved.ankiConnect.knownWords.decks,
'Expected an object mapping deck names to field arrays.',
);
}
const rawSubtitleStyle = isObject(context.src.subtitleStyle) ? context.src.subtitleStyle : {};
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
const knownWordsColor = asColor(knownWordsConfig.color);
if (knownWordsColor !== undefined) {
if (!hasCanonicalKnownWordColor) {
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
}
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
);
} else if (knownWordsConfig.color !== undefined) {
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Expected a hex color value.',
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { ResolveContext } from '../context';
import { asBoolean, asString, isObject } from '../shared';
export function applyLapisResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
if (isObject(ankiConnect.isLapis)) {
const lapisEnabled = asBoolean(ankiConnect.isLapis.enabled);
if (lapisEnabled !== undefined) {
context.resolved.ankiConnect.isLapis.enabled = lapisEnabled;
} else if (ankiConnect.isLapis.enabled !== undefined) {
context.warn(
'ankiConnect.isLapis.enabled',
ankiConnect.isLapis.enabled,
context.resolved.ankiConnect.isLapis.enabled,
'Expected boolean.',
);
}
const sentenceCardModel = asString(ankiConnect.isLapis.sentenceCardModel);
if (sentenceCardModel !== undefined) {
context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel;
} else if (ankiConnect.isLapis.sentenceCardModel !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardModel',
ankiConnect.isLapis.sentenceCardModel,
context.resolved.ankiConnect.isLapis.sentenceCardModel,
'Expected string.',
);
}
if (ankiConnect.isLapis.sentenceCardSentenceField !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardSentenceField',
ankiConnect.isLapis.sentenceCardSentenceField,
'Sentence',
'Deprecated key; sentence-card sentence field is fixed to Sentence.',
);
}
if (ankiConnect.isLapis.sentenceCardAudioField !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardAudioField',
ankiConnect.isLapis.sentenceCardAudioField,
'SentenceAudio',
'Deprecated key; sentence-card audio field is fixed to SentenceAudio.',
);
}
} else if (ankiConnect.isLapis !== undefined) {
context.warn(
'ankiConnect.isLapis',
ankiConnect.isLapis,
context.resolved.ankiConnect.isLapis,
'Expected object.',
);
}
}
+364
View File
@@ -0,0 +1,364 @@
import type { ResolveContext } from '../context';
import { asBoolean, asNumber, asString } from '../shared';
import { asNotificationType, hasOwn } from './shared';
export function applyAnkiLegacyResolution(
context: ResolveContext,
legacy: Record<string, unknown>,
behavior: Record<string, unknown>,
fields: Record<string, unknown>,
media: Record<string, unknown>,
metadata: Record<string, unknown>,
): void {
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
return undefined;
}
return parsed;
};
const asPositiveInteger = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
};
const asPositiveNumber = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || parsed <= 0) {
return undefined;
}
return parsed;
};
const asNonNegativeNumber = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || parsed < 0) {
return undefined;
}
return parsed;
};
const asImageType = (value: unknown): 'static' | 'avif' | undefined => {
return value === 'static' || value === 'avif' ? value : undefined;
};
const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => {
return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined;
};
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
return value === 'append' || value === 'prepend' ? value : undefined;
};
const mapLegacy = <T>(
key: string,
parse: (value: unknown) => T | undefined,
apply: (value: T) => void,
fallback: unknown,
message: string,
): void => {
const value = legacy[key];
if (value === undefined) return;
const parsed = parse(value);
if (parsed === undefined) {
context.warn(`ankiConnect.${key}`, value, fallback, message);
return;
}
apply(parsed);
};
if (!hasOwn(fields, 'audio')) {
mapLegacy(
'audioField',
asString,
(value) => {
context.resolved.ankiConnect.fields.audio = value;
},
context.resolved.ankiConnect.fields.audio,
'Expected string.',
);
}
if (!hasOwn(fields, 'word')) {
mapLegacy(
'wordField',
asString,
(value) => {
context.resolved.ankiConnect.fields.word = value;
},
context.resolved.ankiConnect.fields.word,
'Expected string.',
);
}
if (!hasOwn(fields, 'image')) {
mapLegacy(
'imageField',
asString,
(value) => {
context.resolved.ankiConnect.fields.image = value;
},
context.resolved.ankiConnect.fields.image,
'Expected string.',
);
}
if (!hasOwn(fields, 'sentence')) {
mapLegacy(
'sentenceField',
asString,
(value) => {
context.resolved.ankiConnect.fields.sentence = value;
},
context.resolved.ankiConnect.fields.sentence,
'Expected string.',
);
}
if (!hasOwn(fields, 'miscInfo')) {
mapLegacy(
'miscInfoField',
asString,
(value) => {
context.resolved.ankiConnect.fields.miscInfo = value;
},
context.resolved.ankiConnect.fields.miscInfo,
'Expected string.',
);
}
if (!hasOwn(metadata, 'pattern')) {
mapLegacy(
'miscInfoPattern',
asString,
(value) => {
context.resolved.ankiConnect.metadata.pattern = value;
},
context.resolved.ankiConnect.metadata.pattern,
'Expected string.',
);
}
if (!hasOwn(media, 'generateAudio')) {
mapLegacy(
'generateAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.generateAudio = value;
},
context.resolved.ankiConnect.media.generateAudio,
'Expected boolean.',
);
}
if (!hasOwn(media, 'generateImage')) {
mapLegacy(
'generateImage',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.generateImage = value;
},
context.resolved.ankiConnect.media.generateImage,
'Expected boolean.',
);
}
if (!hasOwn(media, 'imageType')) {
mapLegacy(
'imageType',
asImageType,
(value) => {
context.resolved.ankiConnect.media.imageType = value;
},
context.resolved.ankiConnect.media.imageType,
"Expected 'static' or 'avif'.",
);
}
if (!hasOwn(media, 'imageFormat')) {
mapLegacy(
'imageFormat',
asImageFormat,
(value) => {
context.resolved.ankiConnect.media.imageFormat = value;
},
context.resolved.ankiConnect.media.imageFormat,
"Expected 'jpg', 'png', or 'webp'.",
);
}
if (!hasOwn(media, 'imageQuality')) {
mapLegacy(
'imageQuality',
(value) => asIntegerInRange(value, 1, 100),
(value) => {
context.resolved.ankiConnect.media.imageQuality = value;
},
context.resolved.ankiConnect.media.imageQuality,
'Expected integer between 1 and 100.',
);
}
if (!hasOwn(media, 'imageMaxWidth')) {
mapLegacy(
'imageMaxWidth',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.imageMaxWidth = value;
},
context.resolved.ankiConnect.media.imageMaxWidth,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'imageMaxHeight')) {
mapLegacy(
'imageMaxHeight',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.imageMaxHeight = value;
},
context.resolved.ankiConnect.media.imageMaxHeight,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedFps')) {
mapLegacy(
'animatedFps',
(value) => asIntegerInRange(value, 1, 60),
(value) => {
context.resolved.ankiConnect.media.animatedFps = value;
},
context.resolved.ankiConnect.media.animatedFps,
'Expected integer between 1 and 60.',
);
}
if (!hasOwn(media, 'animatedMaxWidth')) {
mapLegacy(
'animatedMaxWidth',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.animatedMaxWidth = value;
},
context.resolved.ankiConnect.media.animatedMaxWidth,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedMaxHeight')) {
mapLegacy(
'animatedMaxHeight',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.animatedMaxHeight = value;
},
context.resolved.ankiConnect.media.animatedMaxHeight,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedCrf')) {
mapLegacy(
'animatedCrf',
(value) => asIntegerInRange(value, 0, 63),
(value) => {
context.resolved.ankiConnect.media.animatedCrf = value;
},
context.resolved.ankiConnect.media.animatedCrf,
'Expected integer between 0 and 63.',
);
}
if (!hasOwn(media, 'syncAnimatedImageToWordAudio')) {
mapLegacy(
'syncAnimatedImageToWordAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio = value;
},
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio,
'Expected boolean.',
);
}
if (!hasOwn(media, 'audioPadding')) {
mapLegacy(
'audioPadding',
asNonNegativeNumber,
(value) => {
context.resolved.ankiConnect.media.audioPadding = value;
},
context.resolved.ankiConnect.media.audioPadding,
'Expected non-negative number.',
);
}
if (!hasOwn(media, 'fallbackDuration')) {
mapLegacy(
'fallbackDuration',
asPositiveNumber,
(value) => {
context.resolved.ankiConnect.media.fallbackDuration = value;
},
context.resolved.ankiConnect.media.fallbackDuration,
'Expected positive number.',
);
}
if (!hasOwn(media, 'maxMediaDuration')) {
mapLegacy(
'maxMediaDuration',
asNonNegativeNumber,
(value) => {
context.resolved.ankiConnect.media.maxMediaDuration = value;
},
context.resolved.ankiConnect.media.maxMediaDuration,
'Expected non-negative number.',
);
}
if (!hasOwn(behavior, 'overwriteAudio')) {
mapLegacy(
'overwriteAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.overwriteAudio = value;
},
context.resolved.ankiConnect.behavior.overwriteAudio,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'overwriteImage')) {
mapLegacy(
'overwriteImage',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.overwriteImage = value;
},
context.resolved.ankiConnect.behavior.overwriteImage,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'mediaInsertMode')) {
mapLegacy(
'mediaInsertMode',
asMediaInsertMode,
(value) => {
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
},
context.resolved.ankiConnect.behavior.mediaInsertMode,
"Expected 'append' or 'prepend'.",
);
}
if (!hasOwn(behavior, 'highlightWord')) {
mapLegacy(
'highlightWord',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.highlightWord = value;
},
context.resolved.ankiConnect.behavior.highlightWord,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'notificationType')) {
mapLegacy(
'notificationType',
asNotificationType,
(value) => {
context.resolved.ankiConnect.behavior.notificationType = value;
},
context.resolved.ankiConnect.behavior.notificationType,
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
);
}
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
mapLegacy(
'autoUpdateNewCards',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.autoUpdateNewCards = value;
},
context.resolved.ankiConnect.behavior.autoUpdateNewCards,
'Expected boolean.',
);
}
}
@@ -0,0 +1,55 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean } from '../shared';
import { applyModernValue } from './modern-value';
import { asNotificationType } from './shared';
export function applyModernBehaviorResolution(
context: ResolveContext,
behavior: Record<string, unknown>,
): void {
for (const key of [
'overwriteAudio',
'overwriteImage',
'highlightWord',
'autoUpdateNewCards',
] as const) {
applyModernValue(
context,
behavior,
key,
`ankiConnect.behavior.${key}`,
asBoolean,
DEFAULT_CONFIG.ankiConnect.behavior[key],
(value) => {
context.resolved.ankiConnect.behavior[key] = value;
},
'Expected boolean.',
);
}
applyModernValue(
context,
behavior,
'mediaInsertMode',
'ankiConnect.behavior.mediaInsertMode',
(value) => (value === 'append' || value === 'prepend' ? value : undefined),
DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode,
(value) => {
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
},
"Expected 'append' or 'prepend'.",
);
applyModernValue(
context,
behavior,
'notificationType',
'ankiConnect.behavior.notificationType',
asNotificationType,
DEFAULT_CONFIG.ankiConnect.behavior.notificationType,
(value) => {
context.resolved.ankiConnect.behavior.notificationType = value;
},
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
);
}
@@ -0,0 +1,24 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asString } from '../shared';
import { applyModernValue } from './modern-value';
export function applyModernFieldsResolution(
context: ResolveContext,
fields: Record<string, unknown>,
): void {
for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) {
applyModernValue(
context,
fields,
key,
`ankiConnect.fields.${key}`,
asString,
DEFAULT_CONFIG.ankiConnect.fields[key],
(value) => {
context.resolved.ankiConnect.fields[key] = value;
},
'Expected string.',
);
}
}
@@ -0,0 +1,145 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean } from '../shared';
import {
applyModernValue,
asIntegerInRange,
asNonNegativeInteger,
asNonNegativeNumber,
asPositiveNumber,
} from './modern-value';
export function applyModernMediaResolution(
context: ResolveContext,
media: Record<string, unknown>,
): void {
for (const key of [
'generateAudio',
'generateImage',
'syncAnimatedImageToWordAudio',
'normalizeAudio',
'mirrorMpvVolume',
] as const) {
applyModernValue(
context,
media,
key,
`ankiConnect.media.${key}`,
asBoolean,
DEFAULT_CONFIG.ankiConnect.media[key],
(value) => {
context.resolved.ankiConnect.media[key] = value;
},
'Expected boolean.',
);
}
applyModernValue(
context,
media,
'imageType',
'ankiConnect.media.imageType',
(value) => (value === 'static' || value === 'avif' ? value : undefined),
DEFAULT_CONFIG.ankiConnect.media.imageType,
(value) => {
context.resolved.ankiConnect.media.imageType = value;
},
"Expected 'static' or 'avif'.",
);
applyModernValue(
context,
media,
'imageFormat',
'ankiConnect.media.imageFormat',
(value) => (value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined),
DEFAULT_CONFIG.ankiConnect.media.imageFormat,
(value) => {
context.resolved.ankiConnect.media.imageFormat = value;
},
"Expected 'jpg', 'png', or 'webp'.",
);
applyModernValue(
context,
media,
'imageQuality',
'ankiConnect.media.imageQuality',
(value) => asIntegerInRange(value, 1, 100),
DEFAULT_CONFIG.ankiConnect.media.imageQuality,
(value) => {
context.resolved.ankiConnect.media.imageQuality = value;
},
'Expected integer between 1 and 100.',
);
for (const key of [
'imageMaxWidth',
'imageMaxHeight',
'animatedMaxWidth',
'animatedMaxHeight',
] as const) {
applyModernValue(
context,
media,
key,
`ankiConnect.media.${key}`,
asNonNegativeInteger,
DEFAULT_CONFIG.ankiConnect.media[key] ?? 0,
(value) => {
context.resolved.ankiConnect.media[key] = value;
},
'Expected non-negative integer.',
);
}
applyModernValue(
context,
media,
'animatedFps',
'ankiConnect.media.animatedFps',
(value) => asIntegerInRange(value, 1, 60),
DEFAULT_CONFIG.ankiConnect.media.animatedFps,
(value) => {
context.resolved.ankiConnect.media.animatedFps = value;
},
'Expected integer between 1 and 60.',
);
applyModernValue(
context,
media,
'animatedCrf',
'ankiConnect.media.animatedCrf',
(value) => asIntegerInRange(value, 0, 63),
DEFAULT_CONFIG.ankiConnect.media.animatedCrf,
(value) => {
context.resolved.ankiConnect.media.animatedCrf = value;
},
'Expected integer between 0 and 63.',
);
applyModernValue(
context,
media,
'audioPadding',
'ankiConnect.media.audioPadding',
asNonNegativeNumber,
DEFAULT_CONFIG.ankiConnect.media.audioPadding,
(value) => {
context.resolved.ankiConnect.media.audioPadding = value;
},
'Expected non-negative number.',
);
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
applyModernValue(
context,
media,
key,
`ankiConnect.media.${key}`,
asPositiveNumber,
DEFAULT_CONFIG.ankiConnect.media[key],
(value) => {
context.resolved.ankiConnect.media[key] = value;
},
'Expected positive number.',
);
}
}
@@ -0,0 +1,22 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asString } from '../shared';
import { applyModernValue } from './modern-value';
export function applyModernMetadataResolution(
context: ResolveContext,
metadata: Record<string, unknown>,
): void {
applyModernValue(
context,
metadata,
'pattern',
'ankiConnect.metadata.pattern',
asString,
DEFAULT_CONFIG.ankiConnect.metadata.pattern,
(value) => {
context.resolved.ankiConnect.metadata.pattern = value;
},
'Expected string.',
);
}
@@ -0,0 +1,46 @@
import type { ResolveContext } from '../context';
import { asNumber } from '../shared';
import { hasOwn } from './shared';
export function asIntegerInRange(value: unknown, min: number, max: number): number | undefined {
const parsed = asNumber(value);
return parsed !== undefined && Number.isInteger(parsed) && parsed >= min && parsed <= max
? parsed
: undefined;
}
export function asNonNegativeInteger(value: unknown): number | undefined {
const parsed = asNumber(value);
return parsed !== undefined && Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
}
export function asPositiveNumber(value: unknown): number | undefined {
const parsed = asNumber(value);
return parsed !== undefined && parsed > 0 ? parsed : undefined;
}
export function asNonNegativeNumber(value: unknown): number | undefined {
const parsed = asNumber(value);
return parsed !== undefined && parsed >= 0 ? parsed : undefined;
}
export function applyModernValue<T>(
context: ResolveContext,
source: Record<string, unknown>,
key: string,
path: string,
parse: (value: unknown) => T | undefined,
fallback: T,
apply: (value: T) => void,
message: string,
): void {
if (!hasOwn(source, key)) return;
const raw = source[key];
const parsed = parse(raw);
if (parsed === undefined) {
apply(fallback);
context.warn(path, raw, fallback, message);
return;
}
apply(parsed);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ResolveContext } from '../context';
import { isObject } from '../shared';
import { applyAiResolution } from './ai';
import { applyLapisResolution } from './lapis';
import { applyModernBehaviorResolution } from './modern-behavior';
import { applyModernFieldsResolution } from './modern-fields';
import { applyModernMediaResolution } from './modern-media';
import { applyModernMetadataResolution } from './modern-metadata';
import { applyProxyResolution } from './proxy';
import { applyTagsResolution } from './tags';
export function applyAnkiModernResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
behavior: Record<string, unknown>,
media: Record<string, unknown>,
): void {
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
applyModernFieldsResolution(context, fields);
applyModernMediaResolution(context, media);
applyModernBehaviorResolution(context, behavior);
applyModernMetadataResolution(context, metadata);
applyLapisResolution(context, ankiConnect);
applyProxyResolution(context, ankiConnect);
applyAiResolution(context, ankiConnect);
applyTagsResolution(context, ankiConnect);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ResolveContext } from '../context';
import { asBoolean, asNumber, asString, isObject } from '../shared';
export function applyProxyResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
if (isObject(ankiConnect.proxy)) {
const proxy = ankiConnect.proxy;
const proxyEnabled = asBoolean(proxy.enabled);
if (proxyEnabled !== undefined) {
context.resolved.ankiConnect.proxy.enabled = proxyEnabled;
} else if (proxy.enabled !== undefined) {
context.warn(
'ankiConnect.proxy.enabled',
proxy.enabled,
context.resolved.ankiConnect.proxy.enabled,
'Expected boolean.',
);
}
const proxyHost = asString(proxy.host);
if (proxyHost !== undefined && proxyHost.trim().length > 0) {
context.resolved.ankiConnect.proxy.host = proxyHost.trim();
} else if (proxy.host !== undefined) {
context.warn(
'ankiConnect.proxy.host',
proxy.host,
context.resolved.ankiConnect.proxy.host,
'Expected non-empty string.',
);
}
const proxyUpstreamUrl = asString(proxy.upstreamUrl);
if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) {
context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim();
} else if (proxy.upstreamUrl !== undefined) {
context.warn(
'ankiConnect.proxy.upstreamUrl',
proxy.upstreamUrl,
context.resolved.ankiConnect.proxy.upstreamUrl,
'Expected non-empty string.',
);
}
const proxyPort = asNumber(proxy.port);
if (
proxyPort !== undefined &&
Number.isInteger(proxyPort) &&
proxyPort >= 1 &&
proxyPort <= 65535
) {
context.resolved.ankiConnect.proxy.port = proxyPort;
} else if (proxy.port !== undefined) {
context.warn(
'ankiConnect.proxy.port',
proxy.port,
context.resolved.ankiConnect.proxy.port,
'Expected integer between 1 and 65535.',
);
}
} else if (ankiConnect.proxy !== undefined) {
context.warn(
'ankiConnect.proxy',
ankiConnect.proxy,
context.resolved.ankiConnect.proxy,
'Expected object.',
);
}
}
@@ -0,0 +1,9 @@
import { isNotificationType, type NotificationType } from '../../../types/notification';
export function asNotificationType(value: unknown): NotificationType | undefined {
return isNotificationType(value) ? value : undefined;
}
export function hasOwn(obj: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(obj, key);
}
+33
View File
@@ -0,0 +1,33 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
export function applyTagsResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
if (Array.isArray(ankiConnect.tags)) {
const normalizedTags = ankiConnect.tags
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedTags.length === ankiConnect.tags.length) {
context.resolved.ankiConnect.tags = [...new Set(normalizedTags)];
} else {
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
context.warn(
'ankiConnect.tags',
ankiConnect.tags,
context.resolved.ankiConnect.tags,
'Expected an array of non-empty strings.',
);
}
} else if (ankiConnect.tags !== undefined) {
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
context.warn(
'ankiConnect.tags',
ankiConnect.tags,
context.resolved.ankiConnect.tags,
'Expected an array of strings.',
);
}
}
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createStatsMiningContext } from '../stats-server/mining-support.js';
test('mining timing observer errors do not replace successful phase results', async () => {
const { timeMiningPhase } = createStatsMiningContext({
onMiningTiming: () => {
throw new Error('observer failed');
},
});
const result = await timeMiningPhase('word', 'test', async () => 42);
assert.equal(result, 42);
});
test('mining timing observer errors do not replace phase errors', async () => {
const phaseError = new Error('phase failed');
const { timeMiningPhase } = createStatsMiningContext({
onMiningTiming: () => {
throw new Error('observer failed');
},
});
await assert.rejects(
timeMiningPhase('word', 'test', async () => {
throw phaseError;
}),
(error: unknown) => error === phaseError,
);
});
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import {
registerStatsAnalyticsRoutes,
registerStatsIntegrationRoutes,
registerStatsLibraryRoutes,
registerStatsMiningRoutes,
registerStatsStaticRoutes,
} from '../stats-server/routes.js';
describe('stats server route groups', () => {
test('exposes focused registrars for createStatsApp composition', () => {
for (const registrar of [
registerStatsAnalyticsRoutes,
registerStatsLibraryRoutes,
registerStatsIntegrationRoutes,
registerStatsMiningRoutes,
registerStatsStaticRoutes,
]) {
assert.equal(typeof registrar, 'function');
}
});
});
+161 -12
View File
@@ -900,9 +900,11 @@ describe('stats server API routes', () => {
}),
);
const res = await app.request('/api/stats/vocabulary?excludePos=particle,auxiliary');
const res = await app.request(
'/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20',
);
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary']]);
assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]);
});
it('GET /api/stats/vocabulary returns POS fields', async () => {
@@ -1001,6 +1003,36 @@ describe('stats server API routes', () => {
assert.equal(res.status, 404);
});
it('PATCH /api/stats/anime/:animeId/anilist accepts only positive integer AniList ids', async () => {
const assignments: Array<{ animeId: number; body: unknown }> = [];
const app = createStatsApp(
createMockTracker({
reassignAnimeAnilist: async (animeId: number, body: unknown) => {
assignments.push({ animeId, body });
},
}),
);
for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) {
const res = await app.request('/api/stats/anime/1/anilist', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ anilistId }),
});
assert.equal(res.status, 400, `accepted invalid AniList id: ${String(anilistId)}`);
}
assert.deepEqual(assignments, []);
const body = { anilistId: 21_802, titleRomaji: 'Little Witch Academia' };
const res = await app.request('/api/stats/anime/1/anilist', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
assert.equal(res.status, 200);
assert.deepEqual(assignments, [{ animeId: 1, body }]);
});
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anime/1/cover');
@@ -1348,6 +1380,86 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
let yomitanCalls = 0;
let mediaCalls = 0;
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => {
yomitanCalls += 1;
return 777;
},
createMediaGenerator: () => ({
generateAudio: async () => {
mediaCalls += 1;
return Buffer.from('audio');
},
generateScreenshot: async () => {
mediaCalls += 1;
return Buffer.from('image');
},
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: ' ',
}),
});
assert.equal(res.status, 400);
assert.equal(yomitanCalls, 0);
assert.equal(mediaCalls, 0);
});
});
it('POST /api/stats/mine-card does not start word media without a Yomitan bridge', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
let mediaCalls = 0;
const app = createStatsApp(createMockTracker(), {
createMediaGenerator: () => ({
generateAudio: async () => {
mediaCalls += 1;
return Buffer.from('audio');
},
generateScreenshot: async () => {
mediaCalls += 1;
return Buffer.from('image');
},
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
}),
});
assert.equal(res.status, 500);
assert.equal(mediaCalls, 0);
});
});
it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
@@ -2155,7 +2267,7 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
});
});
@@ -2279,8 +2391,8 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const fields = updateRequest?.params?.note?.fields ?? {};
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.jpg">$/);
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.jpg">$/);
assert.equal(fields.ExpressionAudio, undefined);
assert.equal(fields.SelectionText, undefined);
});
@@ -2335,8 +2447,8 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const fields = updateRequest?.params?.note?.fields ?? {};
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.avif">$/);
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.avif">$/);
assert.equal(fields.ExpressionAudio, undefined);
assert.equal(fields.SelectionText, undefined);
});
@@ -2492,7 +2604,7 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
assert.match(
updateRequest?.params?.note?.fields?.Voice ?? '',
/^\[sound:subminer_audio_\d+\.mp3\]$/,
/^\[sound:subminer_audio_\d+_12345\.mp3\]$/,
);
},
{ notesInfoFields: null },
@@ -2580,7 +2692,11 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/);
assert.match(
updateRequest?.params?.note?.fields?.Picture ?? '',
/^<img src="subminer_image_\d+_12345\.jpg">$/,
);
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
});
});
@@ -2670,6 +2786,26 @@ Aligned English subtitle
assert.deepEqual(await res.json(), { ok: true });
});
it('DELETE /api/stats/sessions rejects non-safe or fractional session ids', async () => {
let deleteCalls = 0;
const app = createStatsApp(
createMockTracker({
deleteSessions: async () => {
deleteCalls += 1;
},
}),
);
const res = await app.request('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: '{"sessionIds":[1.5,1e309,9007199254740992]}',
});
assert.equal(res.status, 400);
assert.equal(deleteCalls, 0);
});
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
@@ -2694,8 +2830,10 @@ Aligned English subtitle
const originalFetch = globalThis.fetch;
let acquireCalls = 0;
let recordCalls = 0;
globalThis.fetch = (async () =>
new Response(
let requestSignal: AbortSignal | null | undefined;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
requestSignal = init?.signal;
return new Response(
JSON.stringify({
data: {
Page: {
@@ -2707,7 +2845,8 @@ Aligned English subtitle
status: 200,
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
},
)) as typeof fetch;
);
}) as typeof fetch;
try {
const app = createStatsApp(createMockTracker(), {
@@ -2725,6 +2864,7 @@ Aligned English subtitle
assert.equal(res.status, 200);
assert.equal(acquireCalls, 1);
assert.equal(recordCalls, 1);
assert.ok(requestSignal instanceof AbortSignal);
} finally {
globalThis.fetch = originalFetch;
}
@@ -2923,6 +3063,15 @@ Aligned English subtitle
});
});
it('returns not found for malformed percent-encoded asset paths', async () => {
await withTempDir(async (dir) => {
const app = createStatsApp(createMockTracker(), { staticDir: dir });
const res = await app.request('/assets/%');
assert.equal(res.status, 404);
});
});
it('fetches and serves missing cover art on demand', async () => {
let ensureCalls = 0;
let hasCover = false;
+1 -3
View File
@@ -302,9 +302,7 @@ export function registerAnkiJimakuIpcHandlers(
await deps.onDownloadedSecondarySubtitle(result.path);
}
} else {
logger.error(
`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`,
);
logger.error(`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`);
}
return result;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
import type { Hono } from 'hono';
import { statsJson } from '../../../types/stats-http-contract.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
enrichSessionsWithKnownWordMetrics,
loadKnownWordsSet,
parseEventTypesQuery,
parseIntQuery,
parseTrendFillEmpty,
parseTrendGroupBy,
parseTrendRange,
} from './route-support.js';
export function registerStatsAnalyticsRoutes(
app: Hono,
tracker: ImmersionTrackerService,
options?: { knownWordCachePath?: string },
): void {
app.get('/api/stats/overview', async (c) => {
const [rawSessions, rollups, hints] = await Promise.all([
tracker.getSessionSummaries(5),
tracker.getDailyRollups(14),
tracker.getQueryHints(),
]);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json(statsJson('overview', { sessions, rollups, hints }));
});
app.get('/api/stats/daily-rollups', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 60, 500);
const rollups = await tracker.getDailyRollups(limit);
return c.json(statsJson('dailyRollups', rollups));
});
app.get('/api/stats/monthly-rollups', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 24, 120);
const rollups = await tracker.getMonthlyRollups(limit);
return c.json(statsJson('monthlyRollups', rollups));
});
app.get('/api/stats/streak-calendar', async (c) => {
const days = parseIntQuery(c.req.query('days'), 90, 365);
return c.json(statsJson('streakCalendar', await tracker.getStreakCalendar(days)));
});
app.get('/api/stats/trends/episodes-per-day', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
return c.json(statsJson('episodesPerDay', await tracker.getEpisodesPerDay(limit)));
});
app.get('/api/stats/trends/new-anime-per-day', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
return c.json(statsJson('newAnimePerDay', await tracker.getNewAnimePerDay(limit)));
});
app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
return c.json(statsJson('watchTimePerAnime', await tracker.getWatchTimePerAnime(limit)));
});
app.get('/api/stats/trends/dashboard', async (c) => {
const range = parseTrendRange(c.req.query('range'));
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
return c.json(
statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)),
);
});
app.get('/api/stats/sessions', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const rawSessions = await tracker.getSessionSummaries(limit);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json(statsJson('sessions', sessions));
});
app.get('/api/stats/sessions/:id/timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400);
const rawLimit = c.req.query('limit');
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
const timeline = await tracker.getSessionTimeline(id, limit);
return c.json(statsJson('sessionTimeline', timeline));
});
app.get('/api/stats/sessions/:id/events', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json(statsJson('sessionEvents', []), 400);
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
const eventTypes = parseEventTypesQuery(c.req.query('types'));
const events = await tracker.getSessionEvents(id, limit, eventTypes);
return c.json(statsJson('sessionEvents', events));
});
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
// Get per-line word occurrences for the session.
const wordsByLine = await tracker.getSessionWordsByLine(id);
// Build cumulative filtered occurrence counts per recorded line index.
// The stats UI uses line-count progress to align this series with the session
// timeline, so preserve the stored line position rather than compressing gaps.
const totalLineGroups = new Map<number, number>();
const knownLineGroups = new Map<number, number>();
for (const row of wordsByLine) {
totalLineGroups.set(
row.lineIndex,
(totalLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount,
);
if (knownWordsSet.has(row.headword)) {
knownLineGroups.set(
row.lineIndex,
(knownLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount,
);
}
}
const maxLineIndex = Math.max(...totalLineGroups.keys(), ...knownLineGroups.keys(), -1);
let knownWordsSeen = 0;
let totalWordsSeen = 0;
const knownByLinesSeen: Array<{
linesSeen: number;
knownWordsSeen: number;
totalWordsSeen: number;
}> = [];
for (let lineIdx = 0; lineIdx <= maxLineIndex; lineIdx += 1) {
knownWordsSeen += knownLineGroups.get(lineIdx) ?? 0;
totalWordsSeen += totalLineGroups.get(lineIdx) ?? 0;
knownByLinesSeen.push({
linesSeen: lineIdx,
knownWordsSeen,
totalWordsSeen,
});
}
return c.json(statsJson('sessionKnownWordsTimeline', knownByLinesSeen));
});
}
@@ -0,0 +1,224 @@
import type { Hono } from 'hono';
import type { AnkiConnectConfig } from '../../../types.js';
import {
statsJson,
type StatsAnilistSearchResult,
type StatsAnkiBrowseResponse,
} from '../../../types/stats-http-contract.js';
import type { AnilistRateLimiter } from '../anilist/rate-limiter.js';
import { registerStatsCoverRoutes } from '../stats-cover-routes.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
buildAnkiNotePreview,
countKnownWords,
enrichSessionsWithKnownWordMetrics,
loadKnownWordsSet,
parseIntQuery,
} from './route-support.js';
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
const ANILIST_FETCH_TIMEOUT_MS = 3_000;
export function registerStatsIntegrationRoutes(
app: Hono,
tracker: ImmersionTrackerService,
options?: {
knownWordCachePath?: string;
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
anilistRateLimiter?: AnilistRateLimiter;
resolveAnkiNoteId?: (noteId: number) => number;
},
): void {
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
app.get('/api/stats/anilist/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json(statsJson('anilistSearch', []));
try {
await options?.anilistRateLimiter?.acquire();
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANILIST_FETCH_TIMEOUT_MS),
body: JSON.stringify({
query: `query ($search: String!) {
Page(perPage: 10) {
media(search: $search, type: ANIME) {
id
episodes
season
seasonYear
description(asHtml: false)
coverImage { large medium }
title { romaji english native }
}
}
}`,
variables: { search: query },
}),
});
options?.anilistRateLimiter?.recordResponse(res.headers);
if (res.status === 429) {
return c.json(statsJson('anilistSearch', []));
}
const json = (await res.json()) as {
data?: { Page?: { media?: StatsAnilistSearchResult[] } };
};
return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? []));
} catch {
return c.json(statsJson('anilistSearch', []));
}
});
app.get('/api/stats/known-words', (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
return c.json(statsJson('knownWords', [...knownWordsSet]));
});
app.get('/api/stats/known-words-summary', async (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) {
return c.json(statsJson('knownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }));
}
const headwords = await tracker.getAllDistinctHeadwords();
return c.json(statsJson('knownWordsSummary', countKnownWords(headwords, knownWordsSet)));
});
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) {
return c.json(
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
400,
);
}
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) {
return c.json(
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
);
}
const headwords = await tracker.getAnimeDistinctHeadwords(animeId);
return c.json(statsJson('animeKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
});
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) {
return c.json(
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
400,
);
}
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) {
return c.json(
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
);
}
const headwords = await tracker.getMediaDistinctHeadwords(videoId);
return c.json(statsJson('mediaKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
});
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
if (
typeof body?.anilistId !== 'number' ||
!Number.isInteger(body.anilistId) ||
body.anilistId <= 0
) {
return c.body(null, 400);
}
await tracker.reassignAnimeAnilist(animeId, body);
return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
});
registerStatsCoverRoutes(app, tracker);
app.get('/api/stats/episode/:videoId/detail', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const rawSessions = await tracker.getEpisodeSessions(videoId);
const words = await tracker.getEpisodeWords(videoId);
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json(statsJson('episodeDetail', { sessions, words, cardEvents }));
});
app.post('/api/stats/anki/browse', async (c) => {
const noteId = parseIntQuery(c.req.query('noteId'), 0);
if (noteId <= 0) return c.body(null, 400);
const ankiConfig = getAnkiConnectConfig();
try {
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
body: JSON.stringify({
action: 'guiBrowse',
version: 6,
params: { query: `nid:${noteId}` },
}),
});
const result = (await response.json()) as StatsAnkiBrowseResponse;
return c.json(statsJson('ankiBrowse', result));
} catch {
return c.json(statsJson('error', { error: 'Failed to reach AnkiConnect' }), 502);
}
});
app.post('/api/stats/anki/notesInfo', async (c) => {
const body = await c.req.json().catch(() => null);
const noteIds: number[] = Array.isArray(body?.noteIds)
? body.noteIds.filter(
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
)
: [];
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
const resolvedNoteIds = Array.from(
new Set(
noteIds.map((noteId) => {
const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId);
return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0
? (resolvedNoteId as number)
: noteId;
}),
),
);
try {
const ankiConfig = getAnkiConnectConfig();
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
body: JSON.stringify({
action: 'notesInfo',
version: 6,
params: { notes: resolvedNoteIds },
}),
});
const result = (await response.json()) as {
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
};
return c.json(
statsJson(
'ankiNotesInfo',
(result.result ?? []).map((note) => ({
...note,
preview: buildAnkiNotePreview(note.fields, ankiConfig),
})),
),
);
} catch {
return c.json(statsJson('ankiNotesInfo', []), 502);
}
});
}
@@ -0,0 +1,193 @@
import type { Hono } from 'hono';
import { statsJson } from '../../../types/stats-http-contract.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
buildSentenceSearchOptions,
enrichSessionsWithKnownWordMetrics,
parseBooleanQuery,
parseExcludedWordsBody,
parseIntQuery,
} from './route-support.js';
export function registerStatsLibraryRoutes(
app: Hono,
tracker: ImmersionTrackerService,
options?: {
knownWordCachePath?: string;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
},
): void {
app.get('/api/stats/vocabulary', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const excludePos = c.req
.query('excludePos')
?.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
const vocab = await tracker.getVocabularyStats(limit, excludePos);
return c.json(statsJson('vocabulary', vocab));
});
app.get('/api/stats/excluded-words', async (c) => {
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
});
app.put('/api/stats/excluded-words', async (c) => {
const body = await c.req.json().catch(() => null);
const words = parseExcludedWordsBody(body);
if (!words) return c.body(null, 400);
await tracker.replaceStatsExcludedWords(words);
return c.json(statsJson('setExcludedWords', { ok: true }));
});
app.get('/api/stats/vocabulary/occurrences', async (c) => {
const headword = (c.req.query('headword') ?? '').trim();
const word = (c.req.query('word') ?? '').trim();
const reading = (c.req.query('reading') ?? '').trim();
if (!headword || !word) {
return c.json(statsJson('wordOccurrences', []), 400);
}
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset);
return c.json(statsJson('wordOccurrences', occurrences));
});
app.get('/api/stats/sentences/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json(statsJson('sentenceSearch', []));
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
const searchOptions = await buildSentenceSearchOptions(
query,
searchByHeadword,
options?.resolveSentenceSearchHeadwords,
);
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
return c.json(statsJson('sentenceSearch', rows));
});
app.get('/api/stats/kanji', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const kanji = await tracker.getKanjiStats(limit);
return c.json(statsJson('kanji', kanji));
});
app.get('/api/stats/kanji/occurrences', async (c) => {
const kanji = (c.req.query('kanji') ?? '').trim();
if (!kanji) {
return c.json(statsJson('kanjiOccurrences', []), 400);
}
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset);
return c.json(statsJson('kanjiOccurrences', occurrences));
});
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
const wordId = parseIntQuery(c.req.param('wordId'), 0);
if (wordId <= 0) return c.body(null, 400);
const detail = await tracker.getWordDetail(wordId);
if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
const similarWords = await tracker.getSimilarWords(wordId);
return c.json(statsJson('wordDetail', { detail, animeAppearances, similarWords }));
});
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0);
if (kanjiId <= 0) return c.body(null, 400);
const detail = await tracker.getKanjiDetail(kanjiId);
if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
const words = await tracker.getKanjiWords(kanjiId);
return c.json(statsJson('kanjiDetail', { detail, animeAppearances, words }));
});
app.get('/api/stats/media', async (c) => {
const library = await tracker.getMediaLibrary();
return c.json(statsJson('mediaLibrary', library));
});
app.get('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.json(statsJson('error', null), 400);
const [detail, rawSessions, rollups] = await Promise.all([
tracker.getMediaDetail(videoId),
tracker.getMediaSessions(videoId, 100),
tracker.getMediaDailyRollups(videoId, 90),
]);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json(statsJson('mediaDetail', { detail, sessions, rollups }));
});
app.get('/api/stats/anime', async (c) => {
const rows = await tracker.getAnimeLibrary();
return c.json(statsJson('animeLibrary', rows));
});
app.get('/api/stats/anime/:animeId', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const detail = await tracker.getAnimeDetail(animeId);
if (!detail) return c.body(null, 404);
const [episodes, anilistEntries] = await Promise.all([
tracker.getAnimeEpisodes(animeId),
tracker.getAnimeAnilistEntries(animeId),
]);
return c.json(statsJson('animeDetail', { detail, episodes, anilistEntries }));
});
app.get('/api/stats/anime/:animeId/words', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
if (animeId <= 0) return c.body(null, 400);
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
});
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
if (animeId <= 0) return c.body(null, 400);
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
});
app.patch('/api/stats/media/:videoId/watched', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
await tracker.setVideoWatched(videoId, watched);
return c.json(statsJson('setVideoWatched', { ok: true }));
});
app.delete('/api/stats/sessions', async (c) => {
const body = await c.req.json().catch(() => null);
const ids = Array.isArray(body?.sessionIds)
? body.sessionIds.filter(
(id: unknown): id is number => Number.isSafeInteger(id) && (id as number) > 0,
)
: [];
if (ids.length === 0) return c.body(null, 400);
await tracker.deleteSessions(ids);
return c.json(statsJson('deleteSessions', { ok: true }));
});
app.delete('/api/stats/sessions/:sessionId', async (c) => {
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
if (sessionId <= 0) return c.body(null, 400);
await tracker.deleteSession(sessionId);
return c.json(statsJson('deleteSession', { ok: true }));
});
app.delete('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
await tracker.deleteVideo(videoId);
return c.json(statsJson('deleteVideo', { ok: true }));
});
}
@@ -0,0 +1,469 @@
import type { Hono } from 'hono';
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { AnkiConnectClient } from '../../../anki-connect.js';
import { getConfiguredWordFieldName } from '../../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/animated-image-sync.js';
import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
} from '../secondary-subtitle-sidecar.js';
import {
applyStatsWordAndSentenceCardFields,
createStatsMiningContext,
getStatsDirectMiningAudioFieldNames,
getStatsWordMiningAudioFieldName,
resolveStatsNoteFieldName,
shouldUseStatsLapisKikuCardFields,
statsMiningLogger,
type StatsMiningRouteOptions,
type StatsServerNoteInfo,
} from './mining-support.js';
export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteOptions): void {
const {
getAnkiConnectConfig,
getEffectiveMiningDeckName,
getSecondarySubtitleLanguages,
getStatsMiningAlassPath,
timeMiningPhase,
} = createStatsMiningContext(options);
app.post('/api/stats/mine-card', async (c) => {
const body = await c.req.json().catch(() => null);
const sourcePath = typeof body?.sourcePath === 'string' ? body.sourcePath.trim() : '';
const startMs = typeof body?.startMs === 'number' ? body.startMs : NaN;
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
const word = typeof body?.word === 'string' ? body.word.trim() : '';
const bodySecondaryText =
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
const rawMode = c.req.query('mode');
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
if (
!sourcePath ||
!sentence ||
(mode === 'word' && !word) ||
!Number.isFinite(startMs) ||
!Number.isFinite(endMs)
) {
return c.json(
statsJson('mineCard', {
error: 'sourcePath, sentence, startMs, and endMs are required',
}),
400,
);
}
if (endMs <= startMs) {
return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400);
}
if (!existsSync(sourcePath)) {
return c.json(statsJson('mineCard', { error: 'File not found' }), 404);
}
const ankiConfig = getAnkiConnectConfig();
if (!ankiConfig) {
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
}
const addYomitanNote = options?.addYomitanNote;
if (mode === 'word' && !addYomitanNote) {
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500);
}
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
let retimedSecondaryText = '';
if (mode === 'sentence' && !bodySecondaryText) {
try {
retimedSecondaryText = await (
options?.resolveRetimedSecondarySubtitleText ??
resolveRetimedSecondarySubtitleTextFromSidecar
)({
sourcePath,
startMs,
endMs,
languages: secondarySubtitleLanguages,
alassPath: getStatsMiningAlassPath(),
});
} catch (error) {
statsMiningLogger.warn(
'Failed to resolve retimed secondary subtitle for stats mining:',
error instanceof Error ? error.message : String(error),
);
}
}
const secondaryText =
bodySecondaryText ||
retimedSecondaryText ||
resolveSecondarySubtitleTextFromSidecar({
sourcePath,
startMs,
endMs,
languages: secondarySubtitleLanguages,
});
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
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;
const endSec = endMs / 1000;
const rawDuration = endSec - startSec;
const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec;
const highlightedSentence = word
? sentence.replace(
new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
`<b>${word}</b>`,
)
: sentence;
const generateAudio = ankiConfig.media?.generateAudio !== false;
const generateImage = ankiConfig.media?.generateImage !== false && mode !== 'audio';
const imageType = ankiConfig.media?.imageType ?? 'static';
const syncAnimatedImageToWordAudio =
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
const audioPromise = generateAudio
? timeMiningPhase(mode, 'generateAudio', () =>
mediaGen.generateAudio(
sourcePath,
startSec,
clampedEndSec,
audioPadding,
null,
normalizeAudio,
),
)
: Promise.resolve(null);
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
if (!generateImage) {
return Promise.resolve(null);
}
if (imageType === 'avif') {
return timeMiningPhase(mode, 'generateAnimatedImage', () =>
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
fps: ankiConfig.media?.animatedFps ?? 10,
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
maxHeight: ankiConfig.media?.animatedMaxHeight,
crf: ankiConfig.media?.animatedCrf ?? 35,
leadingStillDuration: animatedLeadInSeconds,
}),
);
}
const midpointSec = (startSec + clampedEndSec) / 2;
return timeMiningPhase(mode, 'generateScreenshot', () =>
mediaGen.generateScreenshot(sourcePath, midpointSec, {
format: ankiConfig.media?.imageFormat ?? 'jpg',
quality: ankiConfig.media?.imageQuality ?? 92,
maxWidth: ankiConfig.media?.imageMaxWidth,
maxHeight: ankiConfig.media?.imageMaxHeight,
}),
);
};
const imagePromise =
mode === 'word' && syncAnimatedImageToWordAudio
? Promise.resolve<Buffer | null>(null)
: createImagePromise();
const errors: string[] = [];
let noteId: number;
let effectiveDeckNamePromise: Promise<string> | null = null;
const getEffectiveDeckNameForRequest = (): Promise<string> => {
effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig);
return effectiveDeckNamePromise;
};
const moveNoteToConfiguredDeck = async (id: number): Promise<void> => {
const deckName = await getEffectiveDeckNameForRequest();
if (!deckName) {
return;
}
try {
const cardIds = await timeMiningPhase(mode, 'findCards', () =>
client.findCards(`nid:${id}`),
);
await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName));
} catch (err) {
errors.push(`deck: ${(err as Error).message}`);
}
};
if (mode === 'word') {
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
timeMiningPhase(
'word',
'addYomitanNote',
() => addYomitanNote!(word),
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
),
audioPromise,
imagePromise,
]);
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
return c.json(
statsJson('mineCard', {
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
}),
502,
);
}
noteId = yomitanResult.value;
await moveNoteToConfiguredDeck(noteId);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
let noteInfo: StatsServerNoteInfo | null = null;
if (
audioBuffer ||
(syncAnimatedImageToWordAudio && generateImage) ||
shouldUseStatsLapisKikuCardFields(ankiConfig)
) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
} catch (err) {
if (syncAnimatedImageToWordAudio && generateImage) {
errors.push(`image: ${(err as Error).message}`);
}
}
}
if (syncAnimatedImageToWordAudio && generateImage) {
try {
const animatedLeadInSeconds = noteInfo
? await resolveAnimatedImageLeadInSeconds({
config: ankiConfig,
noteInfo,
resolveConfiguredFieldName: (candidateNoteInfo, ...preferredNames) =>
resolveStatsNoteFieldName(candidateNoteInfo, ...preferredNames),
retrieveMediaFileBase64: (filename) => client.retrieveMediaFile(filename),
})
: 0;
imageBuffer = await createImagePromise(animatedLeadInSeconds);
} catch (err) {
errors.push(`image: ${(err as Error).message}`);
}
}
if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') {
errors.push('audio: no audio generated');
}
if (generateImage && !imageBuffer) {
errors.push('image: no image generated');
}
const mediaFields: Record<string, string> = {};
const timestamp = Date.now();
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence;
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
try {
await timeMiningPhase('word', 'uploadAudio', () =>
client.storeMediaFile(audioFilename, audioBuffer),
);
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
} catch (err) {
errors.push(`audio upload: ${(err as Error).message}`);
}
}
if (imageBuffer) {
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
try {
await timeMiningPhase('word', 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) {
errors.push(`image upload: ${(err as Error).message}`);
}
}
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
if (miscInfoFieldName) {
const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)';
const filenameWithExt = videoTitle || basename(sourcePath);
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const totalMs = Math.floor(startMs);
const totalSec2 = Math.floor(totalMs / 1000);
const hours = String(Math.floor(totalSec2 / 3600)).padStart(2, '0');
const minutes = String(Math.floor((totalSec2 % 3600) / 60)).padStart(2, '0');
const secs = String(totalSec2 % 60).padStart(2, '0');
const ms = String(totalMs % 1000).padStart(3, '0');
mediaFields[miscInfoFieldName] = pattern
.replace(/%f/g, filenameWithoutExt)
.replace(/%F/g, filenameWithExt)
.replace(/%t/g, `${hours}:${minutes}:${secs}`)
.replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`)
.replace(/<br>/g, '\n');
}
if (Object.keys(mediaFields).length > 0) {
try {
await timeMiningPhase('word', 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) {
errors.push(`update fields: ${(err as Error).message}`);
}
}
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
}
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
const fields: Record<string, string> = {
[sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence,
};
if (mode === 'sentence' && secondaryText) {
fields[translationFieldName] = secondaryText;
}
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
if (mode === 'sentence') {
fields[wordFieldName] = sentence;
} else if (word) {
fields[wordFieldName] = word;
}
if (mode === 'sentence') {
fields['IsSentenceCard'] = 'x';
} else if (mode === 'audio') {
fields['IsAudioCard'] = 'x';
}
}
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
const tags = ankiConfig.tags ?? ['SubMiner'];
const addNotePromise = timeMiningPhase(
mode,
'addNote',
async () =>
client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags),
(id) => ({
noteId: id,
}),
);
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
audioPromise,
imagePromise,
addNotePromise,
]);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
if (addNoteResult.status === 'rejected') {
return c.json(
statsJson('mineCard', {
error: `Failed to add note: ${(addNoteResult.reason as Error).message}`,
}),
502,
);
}
noteId = addNoteResult.value;
await moveNoteToConfiguredDeck(noteId);
const mediaFields: Record<string, string> = {};
const timestamp = Date.now();
let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
} catch {
noteInfo = null;
}
}
if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
try {
await timeMiningPhase(mode, 'uploadAudio', () =>
client.storeMediaFile(audioFilename, audioBuffer),
);
const audioValue = `[sound:${audioFilename}]`;
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) {
mediaFields[fieldName] = audioValue;
}
} catch (err) {
errors.push(`audio upload: ${(err as Error).message}`);
}
}
if (imageBuffer) {
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
try {
await timeMiningPhase(mode, 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) {
errors.push(`image upload: ${(err as Error).message}`);
}
}
if (miscInfoFieldName) {
const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)';
const filenameWithExt = videoTitle || basename(sourcePath);
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const totalMs = Math.floor(startMs);
const totalSec = Math.floor(totalMs / 1000);
const hours = String(Math.floor(totalSec / 3600)).padStart(2, '0');
const minutes = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
const secs = String(totalSec % 60).padStart(2, '0');
const ms = String(totalMs % 1000).padStart(3, '0');
const miscInfo = pattern
.replace(/%f/g, filenameWithoutExt)
.replace(/%F/g, filenameWithExt)
.replace(/%t/g, `${hours}:${minutes}:${secs}`)
.replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`)
.replace(/<br>/g, '\n');
mediaFields[miscInfoFieldName] = miscInfo;
}
if (Object.keys(mediaFields).length > 0) {
try {
await timeMiningPhase(mode, 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) {
errors.push(`update fields: ${(err as Error).message}`);
}
}
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
});
}
@@ -0,0 +1,195 @@
import type { MediaGenerator } from '../../../media-generator.js';
import type { AnkiConnectConfig } from '../../../types.js';
import { createLogger } from '../../../logger.js';
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
export type StatsServerNoteInfo = {
noteId: number;
fields: Record<string, { value: string }>;
};
export type StatsServerMediaGenerator = {
generateAudio: (...args: Parameters<MediaGenerator['generateAudio']>) => Promise<Buffer | null>;
generateScreenshot: (
...args: Parameters<MediaGenerator['generateScreenshot']>
) => Promise<Buffer | null>;
generateAnimatedImage: (
...args: Parameters<MediaGenerator['generateAnimatedImage']>
) => Promise<Buffer | null>;
};
export type StatsMiningTimingEvent = {
mode: 'word' | 'sentence' | 'audio';
phase: string;
elapsedMs: number;
noteId?: number;
};
export type StatsMiningRouteOptions = {
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
secondarySubtitleLanguages?: string[];
getSecondarySubtitleLanguages?: () => string[] | undefined;
statsMiningAlassPath?: string;
getStatsMiningAlassPath?: () => string | null | undefined;
resolveRetimedSecondarySubtitleText?: (
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
addYomitanNote?: (word: string) => Promise<number | null>;
createMediaGenerator?: () => StatsServerMediaGenerator;
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
nowMs?: () => number;
};
type StatsMiningLogger = {
debug: (message: string, ...meta: unknown[]) => void;
warn: (message: string, ...meta: unknown[]) => void;
};
export const statsMiningLogger: StatsMiningLogger = createLogger('stats:mining');
export function resolveStatsNoteFieldName(
noteInfo: StatsServerNoteInfo,
...preferredNames: (string | undefined)[]
): string | null {
for (const preferredName of preferredNames) {
if (!preferredName) continue;
const resolved = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
if (resolved) return resolved;
}
return null;
}
function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const fieldName of fieldNames) {
const normalized = fieldName?.trim();
if (!normalized) continue;
const key = normalized.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(normalized);
}
return result;
}
export function getStatsWordMiningAudioFieldName(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
): string {
return (
(noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio)
: null) ??
ankiConfig.fields?.audio ??
'ExpressionAudio'
);
}
export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean {
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
}
export function applyStatsWordAndSentenceCardFields(
fields: Record<string, string>,
noteInfo: StatsServerNoteInfo | null,
ankiConfig: AnkiConnectConfig,
): void {
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
fields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = '';
}
}
export function getStatsDirectMiningAudioFieldNames(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
mode: 'sentence' | 'audio',
): string[] {
const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio';
if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) {
return [configuredAudioField];
}
const sentenceAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField)
: 'SentenceAudio';
const expressionAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
: configuredAudioField;
return mode === 'sentence'
? uniqueFieldNames(sentenceAudioField)
: uniqueFieldNames(sentenceAudioField, expressionAudioField);
}
export function createStatsMiningContext(options?: StatsMiningRouteOptions) {
const nowMs = options?.nowMs ?? (() => Date.now());
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
const getSecondarySubtitleLanguages = (): string[] =>
options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? [];
const getStatsMiningAlassPath = (): string | null | undefined =>
options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath;
const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise<string> => {
const configuredDeckName = ankiConfig.deck?.trim() ?? '';
if (configuredDeckName) return configuredDeckName;
try {
const yomitanDeckName = await options?.getYomitanAnkiDeckName?.();
return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : '';
} catch (error) {
statsMiningLogger.warn(
'Failed to resolve Yomitan Anki deck for stats mining:',
error instanceof Error ? error.message : String(error),
);
return '';
}
};
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
try {
options?.onMiningTiming?.(event);
} catch {
// Timing observers must not affect mining execution.
}
statsMiningLogger.debug(
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
event,
);
};
const timeMiningPhase = async <T>(
mode: StatsMiningTimingEvent['mode'],
phase: string,
fn: () => Promise<T>,
details?: (value: T) => Partial<StatsMiningTimingEvent>,
): Promise<T> => {
const startedAtMs = nowMs();
try {
const value = await fn();
recordMiningTiming({
mode,
phase,
elapsedMs: nowMs() - startedAtMs,
...details?.(value),
});
return value;
} catch (error) {
recordMiningTiming({ mode, phase, elapsedMs: nowMs() - startedAtMs });
throw error;
}
};
return {
getAnkiConnectConfig,
getEffectiveMiningDeckName,
getSecondarySubtitleLanguages,
getStatsMiningAlassPath,
timeMiningPhase,
};
}
@@ -0,0 +1,258 @@
import { existsSync, readFileSync, statSync } from 'node:fs';
import { extname, resolve, sep } from 'node:path';
import {
getConfiguredSentenceFieldName,
getConfiguredTranslationFieldName,
getConfiguredWordFieldName,
getPreferredNoteFieldValue,
} from '../../../anki-field-config.js';
import type { AnkiConnectConfig } from '../../../types.js';
import type { StatsExcludedWord } from '../../../types/stats-wire.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import { splitSentenceSearchTerms } from '../immersion-tracker/query-lexical.js';
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
export function parseIntQuery(
raw: string | undefined,
fallback: number,
maxLimit?: number,
): number {
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return fallback;
const parsed = Math.floor(n);
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
}
export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
? raw
: '30d';
}
export function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
return raw === 'month' ? 'month' : 'day';
}
export function parseTrendFillEmpty(raw: string | undefined): boolean {
return raw !== 'false';
}
export function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
if (!raw) return undefined;
const parsed = raw
.split(',')
.map((entry) => Number.parseInt(entry.trim(), 10))
.filter((entry) => Number.isInteger(entry) && entry > 0);
return parsed.length > 0 ? parsed : undefined;
}
export function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | null {
if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) {
return null;
}
const words: StatsExcludedWord[] = [];
for (const row of (body as { words: unknown[] }).words) {
if (!row || typeof row !== 'object') return null;
const { headword, word, reading } = row as Record<string, unknown>;
if (typeof headword !== 'string' || typeof word !== 'string' || typeof reading !== 'string') {
return null;
}
words.push({ headword, word, reading });
}
return words;
}
export function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
if (!cachePath || !existsSync(cachePath)) return null;
try {
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
version?: number;
words?: string[];
notes?: Record<string, Array<{ word?: unknown; reading?: unknown }>>;
};
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) {
return new Set(raw.words);
}
if (raw.version === 3 && raw.notes && typeof raw.notes === 'object') {
const words = new Set<string>();
for (const entries of Object.values(raw.notes)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (entry && typeof entry.word === 'string' && entry.word) words.add(entry.word);
}
}
return words;
}
} catch {
// Treat an unreadable cache as unavailable.
}
return null;
}
export function countKnownWords(
headwords: string[],
knownWordsSet: Set<string>,
): { totalUniqueWords: number; knownWordCount: number } {
let knownWordCount = 0;
for (const headword of headwords) {
if (knownWordsSet.has(headword)) knownWordCount += 1;
}
return { totalUniqueWords: headwords.length, knownWordCount };
}
function toKnownWordRate(knownWordsSeen: number, tokensSeen: number): number {
if (!Number.isFinite(knownWordsSeen) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) return 0;
return Number(((knownWordsSeen / tokensSeen) * 100).toFixed(1));
}
function summarizeFilteredWordOccurrences(
wordsByLine: Array<{ lineIndex: number; headword: string; occurrenceCount: number }>,
knownWordsSet: Set<string>,
): { knownWordsSeen: number; totalWordsSeen: number } {
let knownWordsSeen = 0;
let totalWordsSeen = 0;
for (const row of wordsByLine) {
totalWordsSeen += row.occurrenceCount;
if (knownWordsSet.has(row.headword)) knownWordsSeen += row.occurrenceCount;
}
return { knownWordsSeen, totalWordsSeen };
}
export async function enrichSessionsWithKnownWordMetrics<
Session extends { sessionId: number; tokensSeen: number },
>(
tracker: ImmersionTrackerService,
sessions: Session[],
knownWordsCachePath?: string,
): Promise<Array<Session & { knownWordsSeen: number; knownWordRate: number }>> {
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
if (!knownWordsSet) {
return sessions.map((session) => ({ ...session, knownWordsSeen: 0, knownWordRate: 0 }));
}
return Promise.all(
sessions.map(async (session) => {
let knownWordsSeen = 0;
let totalWordsSeen = 0;
try {
const summary = summarizeFilteredWordOccurrences(
await tracker.getSessionWordsByLine(session.sessionId),
knownWordsSet,
);
knownWordsSeen = summary.knownWordsSeen;
totalWordsSeen = summary.totalWordsSeen;
} catch {
knownWordsSeen = 0;
totalWordsSeen = 0;
}
return {
...session,
knownWordsSeen,
knownWordRate: toKnownWordRate(knownWordsSeen, totalWordsSeen),
};
}),
);
}
export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined) return fallback;
const normalized = raw.trim().toLowerCase();
if (!normalized) return fallback;
return !['0', 'false', 'no', 'off'].includes(normalized);
}
function uniqueNonEmptyStrings(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
export async function buildSentenceSearchOptions(
query: string,
searchByHeadword: boolean,
resolveSentenceSearchHeadwords: ((term: string) => Promise<string[]> | string[]) | undefined,
): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> {
if (!searchByHeadword) return undefined;
const headwordTerms: Array<{ term: string; headwords: string[] }> = [];
for (const term of splitSentenceSearchTerms(query)) {
const resolved = resolveSentenceSearchHeadwords
? await resolveSentenceSearchHeadwords(term)
: [term];
const headwords = uniqueNonEmptyStrings(resolved);
if (headwords.length > 0) headwordTerms.push({ term, headwords });
}
return headwordTerms.length > 0 ? { headwordTerms } : undefined;
}
export function buildAnkiNotePreview(
fields: Record<string, { value: string }>,
ankiConfig?: Pick<AnkiConnectConfig, 'fields'>,
): { word: string; sentence: string; translation: string } {
return {
word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]),
sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]),
translation: getPreferredNoteFieldValue(fields, [
getConfiguredTranslationFieldName(ankiConfig),
]),
};
}
function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null {
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
const absoluteStaticDir = resolve(staticDir);
let decodedPath: string;
try {
decodedPath = decodeURIComponent(normalizedPath);
} catch {
return null;
}
const absolutePath = resolve(absoluteStaticDir, decodedPath);
if (
absolutePath !== absoluteStaticDir &&
!absolutePath.startsWith(`${absoluteStaticDir}${sep}`)
) {
return null;
}
if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) return null;
return absolutePath;
}
export function createStatsStaticResponse(staticDir: string, requestPath: string): Response | null {
const absolutePath = resolveStatsStaticPath(staticDir, requestPath);
if (!absolutePath) return null;
const contentType =
STATS_STATIC_CONTENT_TYPES[extname(absolutePath).toLowerCase()] ?? 'application/octet-stream';
return new Response(readFileSync(absolutePath), {
headers: {
'Content-Type': contentType,
'Cache-Control': absolutePath.endsWith('index.html')
? 'no-cache'
: 'public, max-age=31536000, immutable',
},
});
}
+5
View File
@@ -0,0 +1,5 @@
export { registerStatsAnalyticsRoutes } from './analytics-routes.js';
export { registerStatsIntegrationRoutes } from './integration-routes.js';
export { registerStatsLibraryRoutes } from './library-routes.js';
export { registerStatsMiningRoutes } from './mining-routes.js';
export { registerStatsStaticRoutes } from './static-routes.js';
@@ -0,0 +1,26 @@
import type { Hono } from 'hono';
import { createStatsStaticResponse } from './route-support.js';
export function registerStatsStaticRoutes(app: Hono, staticDir?: string): void {
if (!staticDir) return;
app.get('/assets/*', (c) => {
const response = createStatsStaticResponse(staticDir, c.req.path);
if (!response) return c.text('Not found', 404);
return response;
});
app.get('/index.html', (c) => {
const response = createStatsStaticResponse(staticDir, '/index.html');
if (!response) return c.text('Stats UI not built', 404);
return response;
});
app.get('*', (c) => {
const staticResponse = createStatsStaticResponse(staticDir, c.req.path);
if (staticResponse) return staticResponse;
const fallback = createStatsStaticResponse(staticDir, '/index.html');
if (!fallback) return c.text('Stats UI not built', 404);
return fallback;
});
}
@@ -48,7 +48,10 @@ test('parseSyncCliTokens handles the temp-dir protocol modes', () => {
}
assert.equal(parseSyncCliTokens(['sync', '--make-temp', 'host']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error');
assert.equal(
parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind,
'error',
);
});
test('parseSyncCliTokens owns the sync CLI validation rules', () => {
+7 -2
View File
@@ -58,7 +58,9 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
for (let i = 0; i < rest.length; i += 1) {
const token = rest[i]!;
const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token);
const assignValue = valueFlags.get(
token.includes('=') ? token.slice(0, token.indexOf('=')) : token,
);
if (assignValue) {
if (token.includes('=')) {
assignValue(token.slice(token.indexOf('=') + 1));
@@ -109,7 +111,10 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
Boolean(removeTemp),
].filter(Boolean).length;
if (modes === 0) {
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
return {
kind: 'error',
message: 'Sync requires a host, --snapshot <file>, or --merge <file>.',
};
}
if (modes > 1) {
return {
+1 -3
View File
@@ -34,9 +34,7 @@ export function resolveImmersionDbPath(): string {
// no config or unreadable config → default location
}
if (configured) {
return configured.startsWith('~')
? path.join(os.homedir(), configured.slice(1))
: configured;
return configured.startsWith('~') ? path.join(os.homedir(), configured.slice(1)) : configured;
}
return path.join(getDefaultConfigDir(), 'immersion.sqlite');
}
@@ -251,11 +251,7 @@ export function mergeMediaMetadata(
}
}
export function mergeExcludedWords(
local: SyncDb,
remote: SyncDb,
summary: SyncMergeSummary,
): void {
export function mergeExcludedWords(local: SyncDb, remote: SyncDb, summary: SyncMergeSummary): void {
if (
!tableExists(remote, 'imm_stats_excluded_words') ||
!tableExists(local, 'imm_stats_excluded_words')
@@ -147,7 +147,9 @@ export function refreshRollupsForNewSessions(
}
const stampMs = nowDbTimestamp();
const deleteDaily = local.query('DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?');
const deleteDaily = local.query(
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
);
const deleteMonthly = local.query(
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
);
@@ -319,7 +319,8 @@ function applyMergedSessionLifetime(
.get(videoId, sessionId),
);
const isFirstSessionForVideoRun = !mediaLifetime && !hasOtherSessionForVideo;
const isFirstCompletedSessionForVideoRun = watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0;
const isFirstCompletedSessionForVideoRun =
watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0;
const hasOtherSessionOnDay = Boolean(
local
+1 -5
View File
@@ -8,11 +8,7 @@ import {
} from './merge-catalog';
import { mergeSessions } from './merge-sessions';
import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups';
import {
assertMergeableSchema,
createEmptyMergeSummary,
type SyncMergeSummary,
} from './shared';
import { assertMergeableSchema, createEmptyMergeSummary, type SyncMergeSummary } from './shared';
import { openLibsqlSyncDb, type SyncDb } from './libsql-driver';
export type { SyncMergeSummary } from './shared';
+58
View File
@@ -7,6 +7,7 @@ import { DEFAULT_CONFIG } from './config/definitions';
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
import {
configureEarlyAppPaths,
exitBackgroundBootstrap,
normalizeLaunchMpvExtraArgs,
normalizeStartupArgv,
normalizeLaunchMpvTargets,
@@ -21,10 +22,17 @@ import {
shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
applyBackgroundBootstrapCommandLineSwitches,
applyEarlyLinuxCommandLineSwitches,
resolveLinuxPasswordStoreValue,
} from './main-entry-runtime';
test('background bootstrap exits through Electron so Chromium children shut down', () => {
const exitCodes: number[] = [];
exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) });
assert.deepEqual(exitCodes, [0]);
});
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
const originalPlatform = process.platform;
try {
@@ -151,6 +159,56 @@ test('applyEarlyLinuxCommandLineSwitches appends password store before main star
]);
});
test('background bootstrap keeps the GPU in-process so no child outlives its AppImage mount', () => {
const collect = () => {
const switches: Array<[string, string | undefined]> = [];
return {
switches,
commandLine: {
appendSwitch: (name: string, value?: string) => {
switches.push([name, value]);
},
},
};
};
const bootstrap = collect();
applyBackgroundBootstrapCommandLineSwitches(
bootstrap.commandLine,
['SubMiner.AppImage', '--start', '--background'],
{},
'linux',
);
assert.deepEqual(bootstrap.switches, [['in-process-gpu', undefined]]);
const detachedChild = collect();
applyBackgroundBootstrapCommandLineSwitches(
detachedChild.commandLine,
['SubMiner.AppImage', '--start', '--background'],
{ SUBMINER_BACKGROUND_CHILD: '1' },
'linux',
);
assert.deepEqual(detachedChild.switches, []);
const foreground = collect();
applyBackgroundBootstrapCommandLineSwitches(
foreground.commandLine,
['SubMiner.AppImage', '--stop'],
{},
'linux',
);
assert.deepEqual(foreground.switches, []);
const windows = collect();
applyBackgroundBootstrapCommandLineSwitches(
windows.commandLine,
['SubMiner.exe', '--start', '--background'],
{},
'win32',
);
assert.deepEqual(windows.switches, []);
});
test('transported AppImage visibility commands forward through app control', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
+24
View File
@@ -35,6 +35,10 @@ type EarlyAppLike = {
setPath: (name: 'userData', value: string) => void;
};
type BackgroundBootstrapAppLike = {
exit: (code: number) => void;
};
type CommandLineLike = {
appendSwitch: (name: string, value?: string) => void;
};
@@ -132,6 +136,22 @@ export function applyEarlyLinuxCommandLineSwitches(
);
}
// The detach bootstrap exits within milliseconds, but a separate GPU child it
// spawned survives app.exit() and keeps executing from the bootstrap's FUSE
// mount until session teardown finally unmaps it — SIGBUS, surfaced by DrKonqi
// as a "Service Crash" notification on every video close. Keeping the GPU
// in-process means the bootstrap leaves no child behind.
export function applyBackgroundBootstrapCommandLineSwitches(
commandLine: CommandLineLike,
argv: string[],
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform,
): void {
if (platform !== 'linux') return;
if (!shouldDetachBackgroundLaunch(argv, env)) return;
commandLine.appendSwitch('in-process-gpu');
}
function consumesLaunchMpvValue(token: string): boolean {
return (
token.startsWith('--') &&
@@ -241,6 +261,10 @@ export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.Process
return true;
}
export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void {
app.exit(0);
}
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
const args = parseCliArgs(argv);
+5 -1
View File
@@ -3,7 +3,9 @@ import { spawn } from 'node:child_process';
import { app, dialog, shell } from 'electron';
import { printHelp } from './cli/help';
import {
applyBackgroundBootstrapCommandLineSwitches,
configureEarlyAppPaths,
exitBackgroundBootstrap,
normalizeLaunchMpvExtraArgs,
normalizeLaunchMpvTargets,
normalizeStartupArgv,
@@ -174,6 +176,7 @@ function createWindowsRuntimePluginPolicy() {
process.argv = normalizeStartupArgv(process.argv, process.env);
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
applySanitizedEnv(sanitizeStartupEnv(process.env));
applyBackgroundBootstrapCommandLineSwitches(app.commandLine, process.argv, process.env);
const userDataPath = configureEarlyAppPaths(app);
const reportFatalError = createFatalErrorReporter({
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
@@ -308,7 +311,8 @@ async function runEntryProcess(): Promise<void> {
env: sanitizeBackgroundEnv(process.env),
});
child.unref();
process.exit(0);
// Let Electron stop bootstrap Chromium children before its AppImage mount is released.
exitBackgroundBootstrap(app);
return;
}
+397 -659
View File
File diff suppressed because it is too large Load Diff
+104 -104
View File
@@ -52,7 +52,13 @@ function runKeepaliveScript(
return new Promise((resolve, reject) => {
execFile(
'/bin/sh',
['-c', APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, APPIMAGE_MOUNT_KEEPALIVE_LABEL, appImagePath, ...extraArgs],
[
'-c',
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
appImagePath,
...extraArgs,
],
{ timeout: 30_000 },
(error) => {
if (error && typeof error.code !== 'number') {
@@ -69,113 +75,107 @@ function writeExecutable(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, { mode: 0o755 });
}
test(
'keepalive script releases the mount only after straggler processes exit',
{ skip: process.platform !== 'linux' },
async () => {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
const resultsDir = path.join(workDir, 'results');
fs.mkdirSync(resultsDir);
const mountDir = path.join(workDir, 'fake-mount');
fs.mkdirSync(mountDir);
const linuxTest = process.platform === 'linux' ? test : test.skip;
// AppRun leaves behind a straggler that keeps executing *from the mount*
// after AppRun itself exits — mimicking Chromium utility children.
fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler'));
fs.chmodSync(path.join(mountDir, 'straggler'), 0o755);
writeExecutable(
path.join(mountDir, 'AppRun'),
[
'#!/bin/sh',
`"${mountDir}/straggler" 1 &`,
`date +%s%N > "${resultsDir}/apprun-exited"`,
'exit 42',
].join('\n'),
linuxTest('keepalive script releases the mount only after straggler processes exit', async () => {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
const resultsDir = path.join(workDir, 'results');
fs.mkdirSync(resultsDir);
const mountDir = path.join(workDir, 'fake-mount');
fs.mkdirSync(mountDir);
// AppRun leaves behind a straggler that keeps executing *from the mount*
// after AppRun itself exits — mimicking Chromium utility children.
fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler'));
fs.chmodSync(path.join(mountDir, 'straggler'), 0o755);
writeExecutable(
path.join(mountDir, 'AppRun'),
[
'#!/bin/sh',
`"${mountDir}/straggler" 1 &`,
`date +%s%N > "${resultsDir}/apprun-exited"`,
'exit 42',
].join('\n'),
);
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
writeExecutable(
fakeAppImage,
[
'#!/bin/sh',
'if [ "${1:-}" = "--appimage-mount" ]; then',
` echo "${mountDir}"`,
` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
' while :; do sleep 0.05; done',
'fi',
`date +%s%N > "${resultsDir}/direct-run"`,
'exit 0',
].join('\n'),
);
try {
const { status } = await runKeepaliveScript(fakeAppImage);
assert.equal(status, 42, 'exit code of AppRun must be propagated');
assert.ok(
!fs.existsSync(path.join(resultsDir, 'direct-run')),
'must not fall back to direct AppImage run when mount succeeds',
);
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
writeExecutable(
fakeAppImage,
[
'#!/bin/sh',
'if [ "${1:-}" = "--appimage-mount" ]; then',
` echo "${mountDir}"`,
` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
' while :; do sleep 0.05; done',
'fi',
`date +%s%N > "${resultsDir}/direct-run"`,
'exit 0',
].join('\n'),
);
try {
const { status } = await runKeepaliveScript(fakeAppImage);
assert.equal(status, 42, 'exit code of AppRun must be propagated');
assert.ok(
!fs.existsSync(path.join(resultsDir, 'direct-run')),
'must not fall back to direct AppImage run when mount succeeds',
);
// The script does not wait for the holder to finish handling SIGTERM
// (the real runtime unmounts on its own after the signal), so poll.
const releasedMarker = path.join(resultsDir, 'holder-released');
const pollDeadline = Date.now() + 2000;
let holderReleased: number | null = null;
while (holderReleased === null && Date.now() < pollDeadline) {
if (fs.existsSync(releasedMarker)) {
const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim();
if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp);
}
if (holderReleased !== null) break;
await new Promise((r) => setTimeout(r, 25));
// The script does not wait for the holder to finish handling SIGTERM
// (the real runtime unmounts on its own after the signal), so poll.
const releasedMarker = path.join(resultsDir, 'holder-released');
const pollDeadline = Date.now() + 2000;
let holderReleased: number | null = null;
while (holderReleased === null && Date.now() < pollDeadline) {
if (fs.existsSync(releasedMarker)) {
const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim();
if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp);
}
assert.ok(holderReleased !== null, 'holder release timestamp must be recorded');
const appRunExited = Number(
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
);
const drainNs = holderReleased - appRunExited;
assert.ok(
drainNs >= 0.8e9,
`holder must outlive the 1s straggler (drained after ${drainNs / 1e9}s)`,
);
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
if (holderReleased !== null) break;
await new Promise((r) => setTimeout(r, 25));
}
},
);
assert.ok(holderReleased !== null, 'holder release timestamp must be recorded');
test(
'keepalive script falls back to direct run when --appimage-mount fails',
{ skip: process.platform !== 'linux' },
async () => {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
const resultsDir = path.join(workDir, 'results');
fs.mkdirSync(resultsDir);
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
writeExecutable(
fakeAppImage,
[
'#!/bin/sh',
'if [ "${1:-}" = "--appimage-mount" ]; then',
' exit 1',
'fi',
`printf '%s\\n' "$@" > "${resultsDir}/direct-run"`,
'exit 7',
].join('\n'),
const appRunExited = Number(
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
);
const drainNs = holderReleased - appRunExited;
assert.ok(
drainNs >= 0.8e9,
`holder must outlive the 1s straggler (drained after ${drainNs / 1e9}s)`,
);
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
});
try {
const { status } = await runKeepaliveScript(fakeAppImage, ['--start', '--background']);
assert.equal(status, 7, 'direct-run exit code must be propagated');
assert.equal(
fs.readFileSync(path.join(resultsDir, 'direct-run'), 'utf8'),
'--start\n--background\n',
'launch args must be forwarded to the direct run',
);
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
},
);
linuxTest('keepalive script falls back to direct run when --appimage-mount fails', async () => {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
const resultsDir = path.join(workDir, 'results');
fs.mkdirSync(resultsDir);
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
writeExecutable(
fakeAppImage,
[
'#!/bin/sh',
'if [ "${1:-}" = "--appimage-mount" ]; then',
' exit 1',
'fi',
`printf '%s\\n' "$@" > "${resultsDir}/direct-run"`,
'exit 7',
].join('\n'),
);
try {
const { status } = await runKeepaliveScript(fakeAppImage, ['--start', '--background']);
assert.equal(status, 7, 'direct-run exit code must be propagated');
assert.equal(
fs.readFileSync(path.join(resultsDir, 'direct-run'), 'utf8'),
'--start\n--background\n',
'launch args must be forwarded to the direct run',
);
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
});
@@ -0,0 +1,99 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
test('main process call sites bypass zero-logic pass-through wrappers', () => {
const sourcePath = path.join(process.cwd(), 'src/main.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
const sourceFile = ts.createSourceFile(
sourcePath,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const offenders: string[] = [];
for (const statement of sourceFile.statements) {
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) {
if (isRuntimePassthrough(statement, statement.body)) {
offenders.push(statement.name.text);
}
continue;
}
if (!ts.isVariableStatement(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (
ts.isIdentifier(declaration.name) &&
declaration.initializer &&
(ts.isArrowFunction(declaration.initializer) ||
ts.isFunctionExpression(declaration.initializer)) &&
isRuntimePassthrough(declaration.initializer, declaration.initializer.body)
) {
offenders.push(declaration.name.text);
}
}
}
assert.deepEqual(offenders, []);
});
function isRuntimePassthrough(
callable: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression,
body: ts.ConciseBody,
): boolean {
let expression: ts.Expression | undefined;
if (!ts.isBlock(body)) {
expression = body;
} else {
if (body.statements.length !== 1) return false;
const statement = body.statements[0]!;
if (ts.isExpressionStatement(statement)) expression = statement.expression;
if (ts.isReturnStatement(statement)) expression = statement.expression;
}
if (!expression) return false;
if (ts.isAwaitExpression(expression) || ts.isVoidExpression(expression)) {
expression = expression.expression;
}
if (!ts.isCallExpression(expression)) return false;
const root = getCalleeRoot(expression.expression);
if (!root) return false;
if (callable.parameters.length !== expression.arguments.length) return false;
return callable.parameters.every((parameter, index) => {
if (!ts.isIdentifier(parameter.name)) return false;
const argument = expression.arguments[index];
if (!argument) return false;
if (parameter.dotDotDotToken) {
if (!ts.isSpreadElement(argument)) return false;
const spreadExpression = unwrapTransparentExpression(argument.expression);
return ts.isIdentifier(spreadExpression) && spreadExpression.text === parameter.name.text;
}
const argumentExpression = unwrapTransparentExpression(argument);
return ts.isIdentifier(argumentExpression) && argumentExpression.text === parameter.name.text;
});
}
function unwrapTransparentExpression(expression: ts.Expression): ts.Expression {
let current = expression;
while (
ts.isParenthesizedExpression(current) ||
ts.isAsExpression(current) ||
ts.isNonNullExpression(current)
) {
current = current.expression;
}
return current;
}
function getCalleeRoot(expression: ts.LeftHandSideExpression): string {
let current: ts.Expression = expression;
while (ts.isPropertyAccessExpression(current) || ts.isCallExpression(current)) {
current = current.expression;
}
return ts.isIdentifier(current) ? current.text : '';
}
+70 -12
View File
@@ -446,7 +446,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /const trackedGeometry = getCurrentTrackedOverlayGeometry\(\);/);
assert.match(
actionBlock,
/const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/,
);
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
@@ -556,11 +559,60 @@ test('YouTube media cache lifecycle routes through configured status notificatio
);
assert.match(cacheBlock, /variant:\s*'success'/);
assert.match(cacheBlock, /notifyNoQueued:\s*false/);
assert.match(startCacheBlock, /mode:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.mode/);
assert.match(
startCacheBlock,
/maxHeight:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.maxHeight/,
/const mediaCacheConfig = configService\.getConfig\(\)\.youtube\.mediaCache;/,
);
assert.match(startCacheBlock, /mode:\s*mediaCacheConfig\.mode/);
assert.match(startCacheBlock, /maxHeight:\s*mediaCacheConfig\.maxHeight/);
});
test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => {
const source = readMainSource();
const emitBlock = source.match(
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const frequencyOptionsSnapshot = emitBlock?.match(
/const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?<body>[\s\S]*?)\n \};/,
)?.[0];
assert.ok(emitBlock);
assert.ok(frequencyOptionsSnapshot);
assert.match(frequencyOptionsSnapshot, /const frequencyOptions = \{/);
assert.match(frequencyOptionsSnapshot, /enabled:\s*frequencyDictionary\.enabled/);
assert.match(frequencyOptionsSnapshot, /topX:\s*frequencyDictionary\.topX/);
assert.match(frequencyOptionsSnapshot, /mode:\s*frequencyDictionary\.mode/);
assert.equal((frequencyOptionsSnapshot.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
assert.match(emitBlock, /subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/);
assert.match(
emitBlock,
/annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/,
);
});
test('websocket frequency options callbacks each read one configuration snapshot', () => {
const source = readMainSource();
const subtitleBlock = source.match(
/startSubtitleWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startAnnotationWebsocket:/,
)?.groups?.body;
const annotationBlock = source.match(
/startAnnotationWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startTexthooker:/,
)?.groups?.body;
const subtitleFrequencyOptionsCallback = subtitleBlock?.match(
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
)?.groups?.body;
const annotationFrequencyOptionsCallback = annotationBlock?.match(
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
)?.groups?.body;
assert.ok(subtitleFrequencyOptionsCallback);
assert.ok(annotationFrequencyOptionsCallback);
for (const callback of [subtitleFrequencyOptionsCallback, annotationFrequencyOptionsCallback]) {
assert.match(callback, /return \{[\s\S]*enabled:\s*frequencyDictionary\.enabled/);
assert.match(callback, /topX:\s*frequencyDictionary\.topX/);
assert.match(callback, /mode:\s*frequencyDictionary\.mode/);
assert.equal((callback.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
}
});
test('mpv connection flushes queued configured OSD notifications', () => {
@@ -587,11 +639,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
assert.ok(toggleBlock);
assert.match(
setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
);
assert.match(
toggleBlock,
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
);
});
@@ -612,7 +664,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.match(
setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
);
});
@@ -663,14 +715,20 @@ test('Linux visible overlay show starts input grace before first measurement', (
for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) {
assert.ok(block);
assert.ok(
block.indexOf('resetLinuxVisibleOverlayStartupInputPrimer();') <
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();'),
const resetIndex = block.indexOf(
'visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer();',
);
assert.ok(
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();') <
block.indexOf('void primeCurrentSubtitleForVisibleOverlay();'),
const graceIndex = block.indexOf(
'visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace();',
);
const primeIndex = block.indexOf(
'void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay();',
);
assert.ok(resetIndex >= 0);
assert.ok(graceIndex >= 0);
assert.ok(primeIndex >= 0);
assert.ok(resetIndex < graceIndex);
assert.ok(graceIndex < primeIndex);
}
});
@@ -147,10 +147,7 @@ test('runSyncLauncher settles on exit when the terminal event is already parsed'
onEvent: () => {},
spawn,
});
children[0]!.stdout.emit(
'data',
Buffer.from('{"type":"result","ok":true,"error":null}\n'),
);
children[0]!.stdout.emit('data', Buffer.from('{"type":"result","ok":true,"error":null}\n'));
children[0]!.emit('exit', 0, null);
const result = await Promise.race([
+2 -1
View File
@@ -106,7 +106,8 @@ export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
? (parsed as SyncProgressEvent)
: null;
case 'merge-summary':
return (event.target === 'local' || event.target === 'remote') && isMergeSummary(event.summary)
return (event.target === 'local' || event.target === 'remote') &&
isMergeSummary(event.summary)
? (parsed as SyncProgressEvent)
: null;
case 'remote-output':
+12 -2
View File
@@ -23,7 +23,17 @@ test('stats data uses the shared HTTP contract while native dialogs retain IPC',
assert.doesNotMatch(ipcHandlers, /statsGet[A-Z]/);
const apiClient = read('stats/src/lib/api-client.ts');
const statsServer = read('src/core/services/stats-server.ts');
const statsServerRoutes = [
'analytics-routes.ts',
'integration-routes.ts',
'library-routes.ts',
'mining-routes.ts',
].map((filename) => ({
filename,
source: read(`src/core/services/stats-server/${filename}`),
}));
assert.match(apiClient, /StatsHttpClient/);
assert.match(statsServer, /statsJson/);
for (const { filename, source } of statsServerRoutes) {
assert.match(source, /statsJson/, `${filename} must use the shared HTTP contract`);
}
});
+7
View File
@@ -0,0 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as tokenMerger from './token-merger';
test('does not expose the redundant verb non-independent predicate', () => {
assert.equal('isVerbNonIndependent' in tokenMerger, false);
});
+1 -8
View File
@@ -94,10 +94,6 @@ export function isVerb(tok: Token): boolean {
return [PartOfSpeech.verb, PartOfSpeech.bound_auxiliary].includes(tok.partOfSpeech);
}
export function isVerbNonIndependent(): boolean {
return true;
}
export function canReceiveAuxiliary(tok: Token): boolean {
return [PartOfSpeech.verb, PartOfSpeech.bound_auxiliary, PartOfSpeech.i_adjective].includes(
tok.partOfSpeech,
@@ -127,10 +123,7 @@ export function shouldMerge(lastStandaloneToken: Token, token: Token): boolean {
if (isAuxVerb(token)) {
return true;
}
if (isContinuativeForm(lastStandaloneToken) && isVerbSuffix(token)) {
return true;
}
if (isVerbSuffix(token) && isVerbNonIndependent()) {
if (isVerbSuffix(token)) {
return true;
}
}
+1 -3
View File
@@ -60,9 +60,7 @@ export function tsukihimeTrackMatchesLanguages(
export function describeTsukihimeTabLanguages(configuredLanguages: string[]): string {
const normalized = [
...new Set(
configuredLanguages
.map((candidate) => normalizeTsukihimeLangCode(candidate))
.filter(Boolean),
configuredLanguages.map((candidate) => normalizeTsukihimeLangCode(candidate)).filter(Boolean),
),
];
if (normalized.length === 0) return 'English';
+1 -4
View File
@@ -76,10 +76,7 @@ function asFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
export function mapTsukihimeSearchResults(
payload: unknown,
maxResults: number,
): TsukihimeEntry[] {
export function mapTsukihimeSearchResults(payload: unknown, maxResults: number): TsukihimeEntry[] {
if (!isObject(payload) || !Array.isArray(payload.results)) return [];
const entries: TsukihimeEntry[] = [];
for (const item of payload.results) {