Compare commits

..

17 Commits

Author SHA1 Message Date
sudacode a4c12165af fix(sync-ui): exit CLI cleanly when sync window closes
- Use runAppCommandInteractive so sync-window inherits the terminal directly
- Quit app on window-all-closed when launched with --sync-window on macOS
- Catch synchronous onWillQuitCleanup errors to prevent quit getting stuck
2026-07-12 16:39:13 -07:00
sudacode a013a7ea55 feat(sync-ui): defer app quit until async cleanup resolves
- check-host participates in active-run coordination and can be cancelled
- shutdown() cancels and awaits the active launcher run on quit
- onWillQuit calls preventDefault and re-triggers quit once cleanup settles
- Snapshot mode awaits tracker quiescent before writing output
- Auto-scheduler catches synchronous triggerHostSync failures
- SCP endpoint accepts Windows absolute paths; cmd.exe rejects % in quoted values
- runAppCommand unified (inherit vs pipe stdio) in launcher/mpv.ts
- Docs: add --check, --json, --ui, --sync-cli, --make-temp/--remove-temp examples
2026-07-12 03:35:39 -07:00
sudacode f8c10edce0 fix(sync): adopted lexicon frequency no longer double-counts active-session lines
The remote tracker increments imm_words/imm_kanji.frequency live, so a
snapshot holding a stale ACTIVE session (skipped by the merge) has that
session's partial occurrences baked into frequency. A word new to the
local DB adopted that full total, then received the same occurrences
again when the session finalized and merged on a later sync.

Subtract active-session occurrence counts when adopting a new row; they
are re-added exactly once when the session completes. Existing rows were
already safe (isNew guard in addWordOccurrences/addKanjiOccurrences).
Verified red/green: the new two-sync regression test fails without the
subtraction (frequency 9 instead of 5).
2026-07-12 02:18:41 -07:00
sudacode c9f85473bb fix(sync): harden sync CLI, IPC, and UI paths from CodeRabbit review
- reject option-like tokens as flag values (--snapshot --force wrote a
  file named --force); --flag=-value still works
- PowerShell remote quoting uses single-quoted literals so $() in a
  quoted path cannot expand
- sync-hosts.json written via temp file + rename; a crash mid-write
  truncated it and the reader's corrupt-fallback dropped every host
- cancelled sync child escalates SIGTERM -> SIGKILL after 5s grace
- NDJSON progress events validated field-by-field before casting
- snapshot filenames include milliseconds to avoid same-second overwrite
- syncAutoScheduler.stop() wired into will-quit cleanup
- sync --ui exclusivity also rejects --make-temp/--remove-temp/--json
- document --sync-window in app help; group --make-temp/--remove-temp
  under modes in sync usage
2026-07-12 02:10:04 -07:00
sudacode 25cca8ce24 fix(sync): rollup-day month check lands in the wrong month at negative UTC offsets
rollup_day is a local epoch day, but the copy-vs-recompute guard read its
month back via day * 86400 (UTC midnight), which resolves to the previous
civil month for the 1st of a month anywhere west of UTC. Anchor at local
noon (+43200) instead. Pre-existing on main, surfaced by CodeRabbit review.
2026-07-12 02:09:52 -07:00
sudacode 08419fbc8e refactor(sync): launcher sync command proxies to the app's --sync-cli
The engine now executes only inside the app (libsql): subminer sync
rebuilds the equivalent --sync-cli argv and spawns the discovered app
binary with the terminal attached (stdin for ssh prompts, raw NDJSON
stdout, exit-code passthrough). The bun:sqlite driver binding and the
launcher-side engine shims are gone; flow tests moved to
src/core/services/stats-sync/sync-flow.test.ts, ssh tests to src, and
the merge suite now runs through the libsql driver the app ships.
2026-07-12 00:12:33 -07:00
sudacode 94260bab16 feat(sync): Windows machines work as sync remotes
Remote temp dirs are now created and removed by the remote SubMiner
itself (sync --make-temp / --remove-temp, validated against its own
tmpdir) instead of mktemp/rm, and the flow detects the remote shell
(POSIX, cmd, PowerShell) to pick quoting and SubMiner install-location
candidates - %LOCALAPPDATA% app install, launcher shim, or PATH - with
no POSIX PATH prefix on Windows. Remote temp paths are normalized to
forward slashes for scp and the CLI. Verified end-to-end against a
throwaway sshd with the app binary as the resolved remote command.
2026-07-11 23:53:40 -07:00
sudacode 7ed4d4f8e2 docs(sync): remote machine only needs the app - checklist, docs, changelog
Sync window drops the launcher-missing warning (self-spawn always works),
the setup checklist explains automatic remote discovery (SubMiner on
PATH, macOS /Applications, or the optional launcher), and the remote
resolver gains tests for app-binary fallback and --remote-cmd probing.
2026-07-11 20:38:03 -07:00
sudacode cd046b310a feat(sync-ui): sync window self-spawns the app, dropping the local launcher/bun requirement
resolveSyncLauncherCommand now returns process.execPath + --sync-cli
(with the app path prepended in dev runs) instead of hunting for bun and
the bundled launcher script. Same NDJSON child protocol, so the sync
window, checks, and auto-sync scheduler are unchanged.
2026-07-11 20:25:48 -07:00
sudacode ffa183b1a1 feat(sync): headless --sync-cli mode so sync only needs the app installed
The Electron app now answers launcher-style sync argv (--sync-cli sync
[host|--snapshot|--merge] ... plus --help/--version) at entry, before any
window or display initialization, backed by a libsql binding of the shared
stats-sync engine. Works over SSH with no display server, so a remote
machine no longer needs the bun launcher. The launcher accepts --sync-cli
as a no-op so both invocation shapes stay equivalent.
2026-07-11 20:23:22 -07:00
sudacode 04095eebf7 refactor(sync): extract stats-sync engine behind a DB-driver interface
Move snapshot/merge/quiescence engine and ssh helpers from launcher/sync
into src/core/services/stats-sync, parameterized on a minimal SyncDb
driver. The launcher binds it to bun:sqlite via launcher/sync/bun-driver;
the sync command becomes a thin adapter over the shared sync flow.
2026-07-11 20:14:28 -07:00
sudacode 93d4bbe9a5 feat(sync-ui): default auto-sync interval 60 minutes 2026-07-11 19:49:48 -07:00
sudacode cff164183a style(sync-ui): drop last em-dash from launcher warning 2026-07-11 19:46:16 -07:00
sudacode ac72c23dab style(sync-ui): drop em-dashes from sync window copy 2026-07-11 19:45:47 -07:00
sudacode 187437b681 feat(sync-ui): sync window renderer, snapshots in /tmp/subminer-db-snapshots, docs + changelog 2026-07-11 18:28:21 -07:00
sudacode 97aaf44b3c feat(sync-ui): main-process runtime, window, tray entry, CLI wiring for sync window 2026-07-11 18:07:48 -07:00
sudacode 0a3f76c0a8 feat(sync): add sync-hosts store, NDJSON --json mode, and --check to subminer sync 2026-07-11 17:45:27 -07:00
261 changed files with 6674 additions and 12401 deletions
+95 -1
View File
@@ -8,4 +8,98 @@ on:
jobs:
build-test-audit:
uses: ./.github/workflows/quality-gate.yml
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Lint changelog fragments
run: bun run changelog:lint
- name: Lint stats (formatting)
run: bun run lint:stats
- name: Enforce pull request changelog fragments (`skip-changelog` label bypass)
if: github.event_name == 'pull_request'
run: bun run changelog:pr-check --base-ref "origin/${{ github.base_ref }}" --head-ref "HEAD" --labels "${{ join(github.event.pull_request.labels.*.name, ',') }}"
- name: Build (TypeScript check)
# Keep explicit typecheck for fast fail before full build/bundle.
run: bun run typecheck
- name: Verify generated config examples
run: bun run verify:config-example
- name: Test suite (source)
run: bun run test:fast
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Build (bundle)
run: bun run build
- name: Immersion SQLite verification
run: bun run test:immersion:sqlite:dist
- name: Dist smoke suite
run: bun run test:smoke:dist
- name: Security audit
run: bun audit --audit-level high
continue-on-error: true
- name: Build Bun subminer wrapper
run: make build-launcher
- name: Verify Bun subminer wrapper
run: dist/launcher/subminer --help >/dev/null
- name: Enforce generated launcher workflow
run: bash scripts/verify-generated-launcher.sh
+80 -3
View File
@@ -12,9 +12,86 @@ concurrency:
jobs:
quality-gate:
permissions:
contents: read
uses: ./.github/workflows/quality-gate.yml
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Lint stats (formatting)
run: bun run lint:stats
- name: Build (TypeScript check)
run: bun run typecheck
- name: Install Lua
run: |
sudo apt-get update
sudo apt-get install -y lua5.4
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v
- name: Test suite (source)
run: bun run test:fast
- name: Environment suite
run: bun run test:env
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Build (bundle)
run: bun run build
- name: Immersion SQLite verification
run: bun run test:immersion:sqlite:dist
- name: Dist smoke suite
run: bun run test:smoke:dist
build-linux:
needs: [quality-gate]
-117
View File
@@ -1,117 +0,0 @@
name: Quality Gate
on:
workflow_call:
permissions:
contents: read
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Lint changelog fragments
run: bun run changelog:lint
- name: Lint stats (formatting)
run: bun run lint:stats
- name: Enforce pull request changelog fragments (`skip-changelog` label bypass)
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
run: bun run changelog:pr-check --base-ref "origin/$BASE_REF" --head-ref "HEAD" --labels "$PR_LABELS"
- name: Build (TypeScript check)
run: bun run typecheck
- name: Verify generated config examples
run: bun run verify:config-example
- name: Install Lua
run: |
sudo apt-get update
sudo apt-get install -y lua5.4
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v
- name: Test suite (source)
run: bun run test:fast
- name: Environment suite
run: bun run test:env
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Build (bundle)
run: bun run build
- name: Immersion SQLite verification
run: bun run test:immersion:sqlite:dist
- name: Dist smoke suite
run: bun run test:smoke:dist
- name: Security audit
run: bun audit --audit-level high
- name: Build Bun subminer wrapper
run: make build-launcher
- name: Verify Bun subminer wrapper
run: dist/launcher/subminer --help >/dev/null
- name: Enforce generated launcher workflow
run: bash scripts/verify-generated-launcher.sh
+70 -3
View File
@@ -13,9 +13,76 @@ concurrency:
jobs:
quality-gate:
permissions:
contents: read
uses: ./.github/workflows/quality-gate.yml
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Lint stats (formatting)
run: bun run lint:stats
- name: Build (TypeScript check)
run: bun run typecheck
- name: Test suite (source)
run: bun run test:fast
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Build (bundle)
run: bun run build
- name: Immersion SQLite verification
run: bun run test:immersion:sqlite:dist
- name: Dist smoke suite
run: bun run test:smoke:dist
build-linux:
needs: [quality-gate]
-3
View File
@@ -61,6 +61,3 @@ tests/*
favicon.png
.claude/*
!stats/public/favicon.png
# Browser-automation session artifacts (page snapshots, console logs, downloads)
.playwright-mcp/
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Unreleased
### Added
- Stats Sync Without the Launcher: The stats sync engine now runs only inside the app. The sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher — a remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
- Stats Sync With Windows Remotes: Sync now detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp`/`--remove-temp`) instead of `mktemp`/`rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote; SubMiner is found in its default Windows install location automatically.
## v0.18.0 (2026-07-10)
### Added
+1 -1
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 history, then previous / replay / next / select / quit
subminer -H # browse local watch history (replay / next episode / browse)
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
+44 -59
View File
@@ -7,45 +7,36 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@xhayper/discord-rpc": "^1.3.4",
"axios": "^1.18.1",
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"hono": "^4.12.28",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"ws": "^8.21.0",
"ws": "^8.19.0",
},
"devDependencies": {
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron": "42.2.0",
"electron-builder": "26.8.2",
"esbuild": "^0.25.12",
"eslint": "^10.4.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.28.0",
},
},
},
"patchedDependencies": {
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch",
},
"overrides": {
"@xmldom/xmldom": "0.8.13",
"@xmldom/xmldom": "0.8.12",
"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": "5.2.1",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7",
"tar": "7.5.11",
},
"packages": {
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
@@ -54,12 +45,10 @@
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.27.0" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.4", "", {}, "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
@@ -226,11 +215,13 @@
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.4", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.47", "ws": "^8.20.0" } }, "sha512-ff0uEXuibh9wi+l4vOj7xInLUjtlTaQBje/SCyQkeXZ0j2V0y+Zge5PQIQFRHH9TjjGaYJkTofEcQhncM2q7/w=="],
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
@@ -238,7 +229,7 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
@@ -266,7 +257,7 @@
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
"axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="],
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
@@ -276,10 +267,12 @@
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
@@ -354,7 +347,7 @@
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
"discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
"discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"dmg-builder": ["dmg-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-DaWI+p4DOqiFVZFMovdGYammBOyJAiHHFWUTQ0Z7gNc0twfdIN0LvyJ+vFsgZEDR1fjgbpCj690IVtbYIsZObQ=="],
@@ -370,7 +363,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
"electron": ["electron@42.2.0", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw=="],
"electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
@@ -426,6 +419,8 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -434,6 +429,8 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -446,11 +443,11 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
@@ -490,9 +487,9 @@
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.12.28", "", {}, "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA=="],
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
@@ -502,7 +499,7 @@
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="],
@@ -518,7 +515,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -540,7 +537,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-yaml": ["js-yaml@5.2.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -666,6 +663,8 @@
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
@@ -686,7 +685,7 @@
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
@@ -768,7 +767,7 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"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=="],
"tar": ["tar@7.5.11", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ=="],
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
@@ -780,7 +779,7 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
@@ -794,7 +793,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -824,7 +823,7 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
@@ -836,13 +835,11 @@
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"@discordjs/rest/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
@@ -866,10 +863,6 @@
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
@@ -884,12 +877,12 @@
"@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/yauzl/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -900,14 +893,8 @@
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -922,8 +909,6 @@
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -944,14 +929,14 @@
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/yauzl/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
-5
View File
@@ -1,5 +0,0 @@
type: fixed
area: overlay
- Applied configured primary POS exclusions consistently to merged trailing quote-particle tokens, preserved annotations for supplementary-plane kanji, and stopped treating katakana punctuation as kana-only annotation noise.
- Kept kanji vocabulary tagged `名詞/非自立` eligible for N+1 highlighting, consistent with frequency, JLPT, and vocabulary persistence.
@@ -1,4 +0,0 @@
type: changed
area: shortcuts
- Made the clipboard-video playlist shortcut configurable through `shortcuts.appendClipboardVideoToQueue`.
-4
View File
@@ -1,4 +0,0 @@
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,4 +0,0 @@
type: fixed
area: app
- 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.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed `mpv.pauseUntilOverlayReady` releasing playback seconds before tokenization warmup finished: startup subtitle priming emits the current cue untokenized so the overlay can paint early, and that emission was treated as the autoplay-readiness signal as soon as the overlay window loaded. The autoplay gate now ignores untokenized subtitle payloads while tokenization warmup is pending, so playback resumes only after the first tokenized delivery (or the post-warmup release). Most visible when resuming mid-episode or when a subtitle cue starts within the first two seconds.
-4
View File
@@ -1,4 +0,0 @@
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.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: launcher
- Added `subminer sync <host>` to merge immersion stats and watch history between two machines over SSH, with `--push` and `--pull` for one-way insert-only transfers. Each side snapshots its database (`VACUUM INTO`), snapshots are exchanged with `scp`, and each machine merges the other's data as an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved); remote-only historical rollups are copied only when they do not conflict with retained local session history. `subminer sync --snapshot <file>` and `subminer sync --merge <file>` expose the underlying steps for manual transfers. The command refuses to run while the stats daemon or a live mpv session is active (`--force` overrides), ignores stale mpv socket files, keeps the guard in place through local/remote merges, supplies standard SubMiner and Bun paths to non-interactive SSH commands, verifies the remote launcher starts, reports remote stderr on failures, and aborts on stats schema version mismatches.
-4
View File
@@ -1,4 +0,0 @@
type: internal
area: overlay
- Consolidated renderer modal state handling into a descriptor registry.
-4
View File
@@ -1,4 +0,0 @@
type: internal
area: release
- Consolidated pull request, stable release, and prerelease quality checks in one reusable workflow, with Lua mpv plugin tests and blocking high-severity dependency audits running in every gate.
-5
View File
@@ -1,5 +0,0 @@
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.
@@ -0,0 +1,4 @@
type: fixed
area: sync
- Fixed word/kanji frequencies double-counting across syncs when the remote snapshot contained a stale active session (e.g. after a crash): a word new to the local machine adopted the remote's full lifetime frequency, which already included the active session's partial occurrences, and those occurrences were added again when the session finalized and synced. Newly adopted words/kanji now exclude active-session counts, which arrive once the session completes.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: sync
- Fixed stats sync copying a remote daily rollup that should have been recomputed when the day was the 1st of a month and the machine was on a negative UTC offset (the Americas), which could leave that day's totals wrong after a sync. The rollup day is now read back at local noon instead of UTC midnight, so it always resolves to the correct civil month.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: sync
- Added a sync window (`subminer sync --ui`, or **Sync Stats & History** in the tray menu) for cross-machine immersion sync: saved devices with per-host direction (two-way/push/pull) and remove, one-click sync with live stage-by-stage progress and merge summaries, connection testing for first-time setup, cancellable runs with a one-click `--force` retry when the running-app guard trips, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled are synced in the background on a configurable interval while no mpv session or stats server is writing the database, with results reported as overlay notifications. Hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and show up in the window automatically. When launched with `subminer sync --ui`, closing the window also exits the attached CLI process cleanly.
- Added `subminer sync <host> --check` to test the SSH connection and remote launcher availability without syncing, and `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes).
-8
View File
@@ -1,8 +0,0 @@
type: added
area: sync
- Added cross-machine immersion sync for stats and watch history over SSH, available as a window (**Sync Stats & History** in the tray menu, or `subminer sync --ui`) and as a command (`subminer sync <host>`, with `--push` / `--pull` for one-way insert-only transfers). The window keeps saved devices with per-host direction, one-click sync with live stage-by-stage progress and separate merge summaries for each machine, connection testing for first-time setup, cancellable runs while the app/stats server/playback is active, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled sync in the background on a configurable interval, including during playback, with results reported as overlay notifications; hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and appear in the window automatically.
- Merges are an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted: each side snapshots its database (`VACUUM INTO`) from a consistent WAL point, snapshots are exchanged with `scp`, and each machine merges the other's data transactionally. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved), unfinished sessions are excluded until a later sync sees them finalized, and remote-only historical rollups are copied only when they do not conflict with retained local session history. Sync aborts on stats schema version mismatches and refuses to run while the stats daemon or a live mpv session is active (`--force` overrides).
- The sync engine runs only inside the app: the sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher. A remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
- Windows remotes are supported: sync detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp` / `--remove-temp`) instead of `mktemp` / `rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote, found in its default Windows install location automatically.
- Added supporting flags: `subminer sync <host> --check` tests the SSH connection and remote launcher availability without syncing, `subminer sync --snapshot <file>` and `--merge <file>` expose the underlying steps for manual transfers, and `subminer sync --json` emits machine-readable NDJSON progress (the protocol the sync window consumes).
-4
View File
@@ -1,4 +0,0 @@
type: added
area: overlay
- Added TsukiHime subtitle downloads for the current video, with Japanese primary and configured secondary-language tracks loaded directly into mpv.
-4
View File
@@ -1,4 +0,0 @@
type: internal
area: stats
- Removed the unused stats IPC data transport and unified the stats dashboard's HTTP wire types with the backend contract.
+1 -13
View File
@@ -205,13 +205,11 @@
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
"toggleNotificationHistory": "CommandOrControl+N", // Accelerator that toggles the overlay notification history panel.
"appendClipboardVideoToQueue": "CommandOrControl+A" // Accelerator that appends a video path from the clipboard to the mpv playlist.
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
@@ -613,16 +611,6 @@
"maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults.
// ==========================================
// TsukiHime
// TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// Hot-reload: TsukiHime changes apply to the next TsukiHime request.
// ==========================================
"tsukihime": {
"apiBaseUrl": "https://api.tsukihime.org/v1", // Base URL of the TsukiHime API (Animetosho successor). No API key required.
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
-1
View File
@@ -327,7 +327,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Jellyfin', link: '/jellyfin-integration' },
{ text: 'YouTube', link: '/youtube-integration' },
{ text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'TsukiHime', link: '/tsukihime-integration' },
{ text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' },
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Unreleased
**Added**
- Stats Sync Without the Launcher: The stats sync engine now runs only inside the app. The sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher — a remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
- Stats Sync With Windows Remotes: Sync now detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp`/`--remove-temp`) instead of `mktemp`/`rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote; SubMiner is found in its default Windows install location automatically.
## v0.18.0 (2026-07-10)
**Added**
+1 -3
View File
@@ -655,7 +655,6 @@ See `config.example.jsonc` for detailed configuration options.
"openJimaku": "Ctrl+Shift+J",
"toggleSubtitleSidebar": "Backslash",
"toggleNotificationHistory": "CommandOrControl+N",
"appendClipboardVideoToQueue": "CommandOrControl+A",
"multiCopyTimeoutMs": 3000
}
}
@@ -682,7 +681,6 @@ See `config.example.jsonc` for detailed configuration options.
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
| `appendClipboardVideoToQueue` | string \| `null` | Appends a video file path from the clipboard to the mpv playlist (default: `"CommandOrControl+A"`). Works whether the overlay or mpv has focus. |
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
@@ -822,7 +820,7 @@ When automatic card updates are disabled, new cards are detected but not automat
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
| `Ctrl+D` | Open loaded character dictionary manager |
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (configurable via `shortcuts.appendClipboardVideoToQueue`) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
**Multi-line copy workflow:**
+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:env # Launcher smoke + Lua plugin gate
bun run test:launcher:env:src # 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
+14 -17
View File
@@ -73,18 +73,15 @@ 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 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.
- **Replay last watched** replays the most recently watched episode
- **Next episode** plays the episode after the last watched one (continues into the next season directory when the season ends)
- **Browse episodes** lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
## Sync Between Machines
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The sync engine runs only inside the app (`SubMiner --sync-cli sync ...`): the sync window spawns it that way, `subminer sync` is a thin proxy that forwards to the installed app, and the remote side is found automatically whether it has the launcher or just the app. The command-line launcher is optional everywhere.
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The sync engine runs only inside the app (`SubMiner --sync-cli sync ...`): the sync window spawns it that way, `subminer sync` is a thin proxy that forwards to the installed app, and the remote side is found automatically whether it has the launcher or just the app — so the command-line launcher is optional everywhere.
```bash
subminer sync macbook # two-way sync with the host "macbook"
@@ -96,11 +93,11 @@ subminer sync macbook --check # test SSH + remote SubMiner without sync
subminer sync --ui # open the sync window (also in the tray menu)
```
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time. Syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time — syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
For a one-way transfer, `--push` snapshots the local database and merges it into the host without changing the local database. `--pull` snapshots the host and merges it into the local database without changing the host. These modes add missing data; they do not delete destination-only data or make the destination an exact mirror.
Command-line sync defaults to a cold-start safety check: close SubMiner (and stop the background stats daemon with `subminer stats -s`) on both machines before running it, or pass `--force`. Syncs started from the Sync window use live mode automatically, including scheduled auto-syncs while SubMiner or playback is active. SQLite WAL provides a consistent snapshot, the transactional merge serializes with live writes, and each machine's unfinished session is excluded from the transfer; that session syncs normally after it finishes. The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block command-line sync. Both machines must be on the same SubMiner version; otherwise, the sync aborts on a stats schema mismatch.
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block sync. Both machines must be on the same SubMiner version the sync aborts on a stats schema mismatch.
On the remote, sync looks for the `subminer` launcher first (PATH and `~/.local/bin`), then the app binary in `--sync-cli` mode (`SubMiner` on PATH, then the standard macOS `/Applications` and `~/Applications` installs), checking standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`. An AppImage in a custom location can be addressed with `--remote-cmd /path/to/SubMiner.AppImage` (or symlink it as `SubMiner` somewhere on the remote PATH).
@@ -113,22 +110,22 @@ subminer sync --snapshot /tmp/stats.sqlite # write a consistent snapshot of th
subminer sync --merge /tmp/stats.sqlite # merge a snapshot file into the local database
```
Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync. Each machine derives it from its own Anki collection.
Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync — each machine derives it from its own Anki collection.
`subminer sync <host> --check` verifies a host without touching any data: it probes the SSH connection, locates SubMiner on the remote (launcher or app binary), and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes).
`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp <dir>` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically and runs AppImages in Node-only mode, so remote sync does not require a graphical session.
`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp <dir>` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically.
### Sync window
`subminer sync --ui` opens a dedicated window for the same engine in a detached app process, returning the shell immediately. Closing that standalone-launched window exits its app instance. Opening **Sync Stats & History** from the tray keeps the resident app running when the window closes:
`subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine:
- **Devices:** saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically.
- **Add a device:** test SSH + remote SubMiner availability before saving, with a setup checklist for first-time SSH configuration.
- **Activity:** live stage-by-stage progress, remote output, and separate merge summaries (sessions, words, kanji, rollups) for each machine updated by the run. Runs can be cancelled and can proceed while the app, stats server, or playback is active.
- **Snapshots:** create manual database snapshots (stored in `/tmp/subminer-db-snapshots/` by default), merge a snapshot file into the local database, or reveal/delete existing snapshots.
- **Devices** saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically.
- **Add a device** test SSH + remote SubMiner availability before saving, with a setup checklist for first-time SSH configuration.
- **Activity** live stage-by-stage progress, remote output, and a merge summary (sessions, words, kanji, rollups) when a run finishes. Runs can be cancelled, and guard failures offer a one-click `--force` retry.
- **Snapshots** create manual database snapshots (stored in `/tmp/subminer-db-snapshots/` by default), merge a snapshot file into the local database, or reveal/delete existing snapshots.
Hosts with **Auto-sync** enabled are synced in the background on a configurable interval (default every 60 minutes), including during active playback; results surface as overlay notifications. The unfinished playback session is skipped until a later sync sees it finalized. Host bookkeeping lives in `<config dir>/sync-hosts.json`.
Hosts with **Auto-sync** enabled are synced in the background on a configurable interval (default every 60 minutes) whenever no mpv session or stats server is using the database; results surface as overlay notifications. Host bookkeeping lives in `<config dir>/sync-hosts.json`.
## Common Commands
+1 -13
View File
@@ -205,13 +205,11 @@
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
"toggleNotificationHistory": "CommandOrControl+N", // Accelerator that toggles the overlay notification history panel.
"appendClipboardVideoToQueue": "CommandOrControl+A" // Accelerator that appends a video path from the clipboard to the mpv playlist.
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
@@ -613,16 +611,6 @@
"maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults.
// ==========================================
// TsukiHime
// TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// Hot-reload: TsukiHime changes apply to the next TsukiHime request.
// ==========================================
"tsukihime": {
"apiBaseUrl": "https://api.tsukihime.org/v1", // Base URL of the TsukiHime API (Animetosho successor). No API key required.
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
+2 -5
View File
@@ -66,8 +66,9 @@ These control playback and subtitle display. They require overlay window focus.
| `Ctrl+W` | Quit mpv |
| `Right-click` | Toggle pause (outside subtitle area) |
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
@@ -82,17 +83,13 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
`shortcuts.openAnimetosho` remains accepted as a deprecated alias for `shortcuts.openTsukihime`. The current name takes precedence when both are configured.
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
+10 -12
View File
@@ -10,8 +10,6 @@ SubMiner's primary tokenizer is Yomitan itself - subtitle text is tokenized base
Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, auxiliary-stem grammar tails like `そうだ` (MeCab POS3 `助動詞語幹`), repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling.
Kanji vocabulary that MeCab labels `名詞/非自立`, such as `日` or `以外`, remains content for every annotation layer. The `非自立` exclusion only suppresses kana grammar nouns such as `こと` and `もの`.
## N+1 Word Highlighting
N+1 highlighting identifies sentences where you know every word except one, making them ideal mining targets. When enabled, SubMiner builds a local cache of your known vocabulary from Anki and highlights tokens accordingly.
@@ -26,16 +24,16 @@ N+1 highlighting identifies sentences where you know every word except one, maki
**Key settings:**
| Option | Default | Description |
| ----------------------------------------- | ------------ | -------------------------------------------------------- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
| Option | Default | Description |
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only fields can mark unrelated homophones as known, so only include them when that tradeoff is intentional.
-81
View File
@@ -1,81 +0,0 @@
# TsukiHime Integration
[TsukiHime](https://tsukihime.org) tracks anime torrent releases and extracts every attachment - including embedded subtitle tracks - from the release files, hosting them for direct download. SubMiner integrates with the TsukiHime API so you can pull English subtitles for the currently playing episode straight from the overlay, no torrent client involved. Downloaded subtitles are decompressed, saved next to the video, and loaded into mpv immediately.
This is the multi-language companion to the [Jimaku integration](/jimaku-integration). Releases that ship multiple languages (e.g. Netflix `[MultiSub]` rips) expose them all; the modal's tabs pick which ones you see, and each download is saved with its own language suffix.
::: tip Successor to Animetosho
TsukiHime replaces [Animetosho](https://animetosho.org), which stops processing new releases in May 2026. TsukiHime imported the Animetosho index and mirrors its attachment storage, so older releases stay reachable alongside new ones.
:::
::: tip No API key required
Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is the `xz` binary on your `PATH` - TsukiHime serves extracted subtitles xz-compressed, and SubMiner shells out to `xz` to decompress them. Most Linux distributions ship it by default (package `xz` or `xz-utils`).
:::
## How It Works
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab.
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
From there:
1. **Search** - SubMiner queries TsukiHime with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
Because releases on TsukiHime are the same files circulating as torrents, picking the release that matches your local file (same group, same version) gives you subtitles with exact timing - no resync needed. If your file is a raw or from a different group, pick any release of the same episode and adjust timing with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`) if necessary.
### Modal Keyboard Shortcuts
| Key | Action |
| ---------------------------- | ------------------------------- |
| `Enter` (in text field) | Search |
| `Enter` (in list) | Select release / download track |
| `Arrow Up` / `Arrow Down` | Navigate releases or tracks |
| `Arrow Left` / `Arrow Right` | Switch English / Japanese tab |
| `Escape` | Close modal |
## Configuration
The integration works out of the box. An optional `tsukihime` section in `config.jsonc` tunes it:
```jsonc
{
"tsukihime": {
"apiBaseUrl": "https://api.tsukihime.org/v1",
"maxSearchResults": 10,
},
}
```
| Option | Type | Default | Description |
| ---------------------------- | -------- | -------------------------------- | -------------------------------------------------------------------------- |
| `tsukihime.apiBaseUrl` | `string` | `"https://api.tsukihime.org/v1"` | Base URL of the TsukiHime API. Only change this if using a mirror. |
| `tsukihime.maxSearchResults` | `number` | `10` | Maximum number of releases returned per search (the API caps this at 100). |
The keyboard shortcut is configured separately under `shortcuts`:
```jsonc
{
"shortcuts": {
"openTsukihime": "Ctrl+Shift+T", // default; set to null to disable
},
}
```
Existing Animetosho configuration remains compatible. SubMiner treats the old `animetosho` section and `shortcuts.openAnimetosho` setting as deprecated aliases. When old and current names are both present, `tsukihime` and `shortcuts.openTsukihime` take precedence.
## Other Ways to Open It
- CLI: `subminer --open-tsukihime`
- Keybinding command: bind any key to `["__tsukihime-open"]` in the `keybindings` array
The previous `--open-animetosho` flag and `__animetosho-open` keybinding command remain accepted as deprecated aliases.
## Troubleshooting
- **"xz binary not found"** - install `xz`/`xz-utils` with your package manager.
- **"Batch releases are not supported"** - TsukiHime only exposes extracted attachments for single-file torrents. Pick the single-episode release for your episode instead of a season batch.
- **"No text subtitle tracks in this release"** - the release only carries image-based subtitles (PGS/VobSub) or none at all; try a different release (fansub and SubsPlease-style releases almost always carry ASS tracks).
- **Timing is off** - the subtitle came from a different release than your video file. Use the subtitle sync modal (`Ctrl+Alt+S`) or pick the release matching your file exactly.
+2 -5
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 history, then choose previous/replay/next after playback
subminer -H # Browse watch history (replay/continue episodes, fzf or rofi picker)
subminer app --setup # Open first-run setup popup
subminer --version # Print the launcher's version
subminer -v # Same as above
@@ -151,7 +151,6 @@ SubMiner.AppImage --show-visible-overlay # Force show visible overl
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
@@ -173,9 +172,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
SubMiner.AppImage --help # Show all options
```
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
The previous `--open-animetosho` flag remains accepted as a deprecated alias for `--open-tsukihime`.
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
-1
View File
@@ -85,7 +85,6 @@ Notes:
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
- CI, stable release, and prerelease workflows call the shared `.github/workflows/quality-gate.yml`; update that reusable workflow when changing common test coverage. Its environment suite includes the Lua mpv plugin tests.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- Stable release tags update `https://docs.subminer.moe/` and `https://docs.subminer.moe/v/<version>/` through `.github/workflows/docs-pages.yml`; `/main/` continues to show development docs from `main`.
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
-2
View File
@@ -31,8 +31,6 @@ 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
+3 -8
View File
@@ -3,7 +3,7 @@
# Domain Ownership
Status: active
Last verified: 2026-07-15
Last verified: 2026-05-23
Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface
@@ -16,9 +16,7 @@ Read when: you need to find the owner module for a behavior or test surface
## Product / Integration Domains
- Config system: `src/config/`; Anki resolution is composed by
`src/config/resolve/anki-connect.ts` from focused resolvers in
`src/config/resolve/anki-connect/`
- Config system: `src/config/`
- 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/`
@@ -28,9 +26,7 @@ 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 HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/`
- Stats SPA: `stats/`
- Stats app: `stats/`
- Public docs site: `docs-site/`
## Shared Contract Entry Points
@@ -43,7 +39,6 @@ Read when: you need to find the owner module for a behavior or test surface
- Runtime-option contracts: `src/types/runtime-options.ts`
- Settings UI contracts: `src/types/settings.ts`
- Session-binding contracts: `src/types/session-bindings.ts`
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
- Compatibility-only barrel: `src/types.ts`
## Ownership Heuristics
@@ -33,10 +33,6 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
## Contract
Stats data crosses the process boundary over HTTP only. Shared JSON models live in `src/types/stats-wire.ts`; endpoint request/response mappings and the client interface live in `src/types/stats-http-contract.ts`. The server and stats client both type-check against those files. `stats/src/types/stats.ts` is a compatibility re-export, not an independent contract copy.
The stats preload bridge remains limited to native window behavior such as confirmation-dialog layering. Do not add stats data request channels back to Electron IPC.
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
+2 -14
View File
@@ -17,12 +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, 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.
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv
plugin tests run for every pull request and tagged release.
unit, `scripts/**`, and the compiled runtime-compat slice.
## Default Handoff Gate
@@ -57,14 +52,7 @@ bun run docs:build
- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output.
- Machine-readable output lands at `coverage/test-src/lcov.info`.
- Every reusable quality-gate run uploads that LCOV file as the
`coverage-test-src` artifact.
## Dependency Audit Policy
- `bun audit --audit-level high` blocks the reusable quality gate.
- Keep security overrides and dependency patches at the minimum fixed version.
Remove them after the owning package ships and adopts a compatible fix.
- CI and release quality-gate runs upload that LCOV file as the `coverage-test-src` artifact.
## Rules
+4 -8
View File
@@ -1,17 +1,14 @@
import {
launchAppCommandDetached,
launchAppBackgroundDetached,
launchTexthookerOnly,
runAppCommandInteractive,
runAppCommandWithInherit,
} from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
type AppCommandDeps = {
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
launchSyncUiDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
runAppCommandInteractive: (appPath: string, appArgs: string[]) => void;
launchAppBackgroundDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
@@ -20,8 +17,7 @@ type AppCommandDeps = {
const defaultAppCommandDeps: AppCommandDeps = {
runAppCommandWithInherit,
launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
runAppCommandInteractive,
launchAppBackgroundDetached,
};
@@ -38,7 +34,7 @@ export function runAppPassthroughCommand(
return true;
}
if (args.syncUi) {
deps.launchSyncUiDetached(appPath, args.logLevel);
deps.runAppCommandInteractive(appPath, ['--sync-window']);
return true;
}
if (!args.appPassthrough) {
+6 -6
View File
@@ -206,7 +206,7 @@ test('app command starts default macOS background app detached from launcher', (
runAppCommandWithInherit: () => {
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
runAppCommandInteractive: () => calls.push('interactive'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -226,7 +226,7 @@ test('app command starts default Linux background app detached from launcher', (
runAppCommandWithInherit: () => {
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
runAppCommandInteractive: () => calls.push('interactive'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -247,7 +247,7 @@ test('app command keeps explicit passthrough args attached', () => {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
launchSyncUiDetached: () => detached.push('sync-ui'),
runAppCommandInteractive: () => detached.push('interactive'),
launchAppBackgroundDetached: () => {
detached.push('detached');
},
@@ -258,19 +258,19 @@ test('app command keeps explicit passthrough args attached', () => {
assert.deepEqual(detached, []);
});
test('sync UI command launches the app detached from the terminal', () => {
test('sync UI command attaches the app directly to the terminal', () => {
const context = createContext();
context.args.syncUi = true;
const calls: string[] = [];
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
runAppCommandInteractive: (_appPath, appArgs) => calls.push(`direct:${appArgs.join(' ')}`),
launchAppBackgroundDetached: () => calls.push('detached'),
});
assert.equal(handled, true);
assert.deepEqual(calls, ['sync-ui:/tmp/subminer.app:warn']);
assert.deepEqual(calls, ['direct:--sync-window']);
});
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
+9 -151
View File
@@ -12,7 +12,6 @@ import {
} from '../picker.js';
import {
findNextEpisode,
findPreviousEpisode,
groupHistoryBySeries,
listSeasonDirs,
materializeCoverArt,
@@ -24,139 +23,6 @@ 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;
}
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');
@@ -276,7 +142,7 @@ function browseEpisodes(
if (seasons.length > 1) {
const idx = pickIndex(
seasons.map((season) => season.name),
`${entry.displayName}: Season`,
`${entry.displayName} Season`,
args.useRofi,
themePath,
);
@@ -289,9 +155,7 @@ function browseEpisodes(
return pickEpisodeFromDir(dir, context);
}
export async function runHistoryCommand(
context: LauncherCommandContext,
): Promise<HistoryPlaybackSelection | null> {
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
const { args, scriptPath } = context;
checkPickerDependencies(args);
@@ -334,15 +198,14 @@ export async function runHistoryCommand(
const lastExists = fs.existsSync(lastPath);
const nextEpisode = findNextEpisode(lastPath);
const actions: HistorySessionMenuAction[] = [];
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
if (lastExists) {
actions.push({ kind: 'replay', label: `Replay last watched: ${path.basename(lastPath)}` });
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: 'next', label: `Next episode ${path.basename(nextEpisode)}` });
}
actions.push({ kind: 'browse', label: 'Browse episodes' });
actions.push({ kind: 'quit', label: 'Quit SubMiner' });
const entryIcon = seriesIcons[seriesIdx] ?? null;
const actionIdx = pickIndex(
@@ -356,15 +219,10 @@ export async function runHistoryCommand(
switch (actions[actionIdx]!.kind) {
case 'replay':
return { entry, videoPath: lastPath, themePath, entryIcon };
return lastPath;
case 'next':
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
case 'browse': {
const videoPath = browseEpisodes(entry, context, themePath);
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
}
case 'previous':
case 'quit':
return null;
return nextEpisode;
case 'browse':
return browseEpisodes(entry, context, themePath);
}
}
-259
View File
@@ -1,259 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { 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 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']);
});
+12 -16
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 { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
function createContext(): LauncherCommandContext {
@@ -37,7 +37,17 @@ function createContext(): LauncherCommandContext {
useRofi: false,
history: false,
sync: false,
syncCliTokens: [],
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncUi: false,
logLevel: 'info',
logRotation: 7,
@@ -103,20 +113,6 @@ 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();
+1 -5
View File
@@ -30,7 +30,6 @@ 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({
@@ -93,10 +92,8 @@ async function chooseTarget(
return { target: selected, kind: 'file' };
}
export function registerCleanup(context: LauncherCommandContext): void {
function registerCleanup(context: LauncherCommandContext): void {
const { args, processAdapter } = context;
if (cleanupRegisteredProcessAdapters.has(processAdapter)) return;
processAdapter.onSignal('SIGINT', () => {
stopOverlay(args);
processAdapter.exit(130);
@@ -105,7 +102,6 @@ export function registerCleanup(context: LauncherCommandContext): void {
stopOverlay(args);
processAdapter.exit(143);
});
cleanupRegisteredProcessAdapters.add(processAdapter);
}
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
+71 -31
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
import { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
import { buildSyncCliArgv, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
function makeContext(
overrides: Partial<Args>,
@@ -11,7 +11,17 @@ function makeContext(
return {
args: {
sync: true,
syncCliTokens: [],
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
logLevel: 'warn',
...overrides,
} as Args,
@@ -25,6 +35,24 @@ function makeContext(
} as unknown as LauncherCommandContext;
}
function makeArgs(overrides: Partial<Parameters<typeof buildSyncCliArgv>[0]>) {
return {
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both' as const,
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
logLevel: 'warn' as const,
...overrides,
};
}
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
const deps: Partial<SyncCommandDeps> = {
@@ -34,7 +62,7 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
};
assert.equal(
await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps),
await runSyncCommand(makeContext({ syncHost: 'media-box', syncJson: true }), deps),
true,
);
assert.deepEqual(spawned, [
@@ -48,32 +76,19 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
assert.equal(spawned.length, 1);
});
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
const spawned: string[][] = [];
const deps: Partial<SyncCommandDeps> = {
runAppCommand: (_appPath, appArgs) => {
spawned.push(appArgs);
},
};
await runSyncCommand(
makeContext({
syncCliTokens: [
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
],
logLevel: 'debug',
}),
deps,
);
assert.deepEqual(spawned, [
test('buildSyncCliArgv forwards every sync option', () => {
assert.deepEqual(
buildSyncCliArgv(
makeArgs({
syncHost: 'media-box',
syncDirection: 'pull',
syncRemoteCmd: '/opt/SubMiner.AppImage',
syncDbPath: '/tmp/db.sqlite',
syncForce: true,
syncJson: true,
logLevel: 'debug',
}),
),
[
'--sync-cli',
'sync',
@@ -88,7 +103,32 @@ test('runSyncCommand forwards tokens verbatim and appends the effective log leve
'--log-level',
'debug',
],
]);
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncSnapshotPath: '/tmp/out.sqlite' })),
['--sync-cli', 'sync', '--snapshot', '/tmp/out.sqlite', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncHost: 'media-box', syncCheck: true })),
['--sync-cli', 'sync', 'media-box', '--check', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncMakeTemp: true })),
['--sync-cli', 'sync', '--make-temp', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncRemoveTempPath: '/tmp/subminer-sync-x' })),
['--sync-cli', 'sync', '--remove-temp', '/tmp/subminer-sync-x', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncMergePath: '/tmp/in.sqlite', syncForce: true })),
['--sync-cli', 'sync', '--merge', '/tmp/in.sqlite', '--force', '--log-level', 'warn'],
);
});
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
@@ -102,7 +142,7 @@ test('runSyncCommand fails with a clear message when the app binary is missing',
};
await assert.rejects(
() => runSyncCommand(makeContext({ syncCliTokens: ['media-box'] }, null), deps),
() => runSyncCommand(makeContext({ syncHost: 'media-box' }, null), deps),
/SubMiner app binary not found \(sync runs inside the app\)/,
);
});
+40 -12
View File
@@ -1,6 +1,6 @@
import { SYNC_CLI_FLAG } from '../../src/core/services/stats-sync/cli-args.js';
import { fail } from '../log.js';
import { runAppCommandInteractive } from '../mpv.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
export interface SyncCommandDeps {
@@ -13,12 +13,46 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
fail,
};
type SyncArgs = Pick<
Args,
| 'syncHost'
| 'syncSnapshotPath'
| 'syncMergePath'
| 'syncDirection'
| 'syncRemoteCmd'
| 'syncDbPath'
| 'syncForce'
| 'syncJson'
| 'syncCheck'
| 'syncMakeTemp'
| 'syncRemoveTempPath'
| 'logLevel'
>;
/** Rebuild the app's --sync-cli argv from the launcher's parsed sync args. */
export function buildSyncCliArgv(args: SyncArgs): string[] {
const argv = ['--sync-cli', 'sync'];
if (args.syncHost) argv.push(args.syncHost);
if (args.syncSnapshotPath) argv.push('--snapshot', args.syncSnapshotPath);
if (args.syncMergePath) argv.push('--merge', args.syncMergePath);
if (args.syncMakeTemp) argv.push('--make-temp');
if (args.syncRemoveTempPath) argv.push('--remove-temp', args.syncRemoveTempPath);
if (args.syncDirection === 'push') argv.push('--push');
if (args.syncDirection === 'pull') argv.push('--pull');
if (args.syncCheck) argv.push('--check');
if (args.syncRemoteCmd) argv.push('--remote-cmd', args.syncRemoteCmd);
if (args.syncDbPath) argv.push('--db', args.syncDbPath);
if (args.syncForce) argv.push('--force');
if (args.syncJson) argv.push('--json');
argv.push('--log-level', args.logLevel);
return argv;
}
/**
* `subminer sync` is a thin proxy: the sync engine only executes inside the
* SubMiner app (--sync-cli mode, libsql). The launcher contributes its
* parser/help and app discovery; the app's parseSyncCliTokens owns validation,
* so its errors reach the terminal through the child's inherited stdio. The
* child owns the terminal and its exit code becomes the launcher's.
* SubMiner app (--sync-cli mode, libsql), so the launcher and the app cannot
* drift apart. The launcher contributes its parser/help and app discovery;
* the child owns the terminal and its exit code becomes the launcher's.
*/
export async function runSyncCommand(
context: LauncherCommandContext,
@@ -34,12 +68,6 @@ export async function runSyncCommand(
);
return true; // fail() never returns; this only satisfies control-flow analysis
}
deps.runAppCommand(context.appPath, [
SYNC_CLI_FLAG,
'sync',
...context.args.syncCliTokens,
'--log-level',
context.args.logLevel,
]);
deps.runAppCommand(context.appPath, buildSyncCliArgv(context.args));
return true;
}
+44 -4
View File
@@ -136,7 +136,17 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
@@ -187,7 +197,17 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
@@ -231,7 +251,17 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
@@ -273,7 +303,17 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
+22 -2
View File
@@ -200,7 +200,17 @@ export function createDefaultArgs(
useRofi: false,
history: false,
sync: false,
syncCliTokens: [],
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncUi: false,
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
@@ -269,7 +279,17 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
}
if (invocations.syncTriggered) {
parsed.sync = true;
parsed.syncCliTokens = invocations.syncCliTokens;
parsed.syncHost = invocations.syncHost ?? '';
parsed.syncSnapshotPath = invocations.syncSnapshotPath ?? '';
parsed.syncMergePath = invocations.syncMergePath ?? '';
parsed.syncDirection = invocations.syncDirection;
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
parsed.syncDbPath = invocations.syncDbPath ?? '';
parsed.syncForce = invocations.syncForce;
parsed.syncJson = invocations.syncJson;
parsed.syncCheck = invocations.syncCheck;
parsed.syncMakeTemp = invocations.syncMakeTemp;
parsed.syncRemoveTempPath = invocations.syncRemoveTempPath ?? '';
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
}
if (invocations.syncUiTriggered) {
+30 -67
View File
@@ -43,66 +43,42 @@ test('parseCliPrograms captures texthooker browser-open flag', () => {
assert.equal(result.invocations.texthookerOpenBrowser, true);
});
test('parseCliPrograms lowers sync options into app-owned CLI tokens', () => {
test('parseCliPrograms captures one-way sync directions', () => {
const push = parseCliPrograms(['sync', 'media-box', '--push'], 'subminer');
assert.equal(push.invocations.syncTriggered, true);
assert.deepEqual(push.invocations.syncCliTokens, ['media-box', '--push']);
const pull = parseCliPrograms(['sync', 'media-box', '--pull'], 'subminer');
assert.deepEqual(pull.invocations.syncCliTokens, ['media-box', '--pull']);
const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer');
assert.deepEqual(check.invocations.syncCliTokens, ['media-box', '--check', '--json']);
const full = parseCliPrograms(
[
'sync',
'media-box',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--log-level',
'debug',
],
'subminer',
);
assert.deepEqual(full.invocations.syncCliTokens, [
'media-box',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
]);
assert.equal(full.invocations.syncLogLevel, 'debug');
const snapshot = parseCliPrograms(['sync', '--snapshot', '/tmp/out.sqlite'], 'subminer');
assert.deepEqual(snapshot.invocations.syncCliTokens, ['--snapshot', '/tmp/out.sqlite']);
const merge = parseCliPrograms(['sync', '--merge', '/tmp/in.sqlite'], 'subminer');
assert.deepEqual(merge.invocations.syncCliTokens, ['--merge', '/tmp/in.sqlite']);
const makeTemp = parseCliPrograms(['sync', '--make-temp'], 'subminer');
assert.deepEqual(makeTemp.invocations.syncCliTokens, ['--make-temp']);
const removeTemp = parseCliPrograms(
['sync', '--remove-temp', '/tmp/subminer-sync-x'],
'subminer',
);
assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']);
assert.equal(push.invocations.syncDirection, 'push');
assert.equal(pull.invocations.syncDirection, 'pull');
});
test('parseCliPrograms leaves sync validation to the app parser', () => {
// Invalid combinations are forwarded; the app's parseSyncCliTokens rejects them.
const invalid = parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer');
assert.equal(invalid.invocations.syncTriggered, true);
assert.deepEqual(invalid.invocations.syncCliTokens, ['media-box', '--push', '--pull']);
test('parseCliPrograms rejects conflicting or hostless one-way sync directions', () => {
assert.throws(
() => parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer'),
/--push and --pull cannot be combined/,
);
assert.throws(
() => parseCliPrograms(['sync', '--snapshot', '/tmp/stats.sqlite', '--push'], 'subminer'),
/--push and --pull require a host/,
);
});
const empty = parseCliPrograms(['sync'], 'subminer');
assert.equal(empty.invocations.syncTriggered, true);
assert.deepEqual(empty.invocations.syncCliTokens, []);
test('parseCliPrograms captures sync --json and --check flags', () => {
const json = parseCliPrograms(['sync', 'media-box', '--json'], 'subminer');
assert.equal(json.invocations.syncJson, true);
assert.equal(json.invocations.syncCheck, false);
const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer');
assert.equal(check.invocations.syncCheck, true);
assert.equal(check.invocations.syncJson, true);
assert.equal(check.invocations.syncHost, 'media-box');
});
test('parseCliPrograms rejects invalid sync --check combinations', () => {
assert.throws(() => parseCliPrograms(['sync', '--check'], 'subminer'), /--check requires a host/);
assert.throws(
() => parseCliPrograms(['sync', 'media-box', '--check', '--push'], 'subminer'),
/--check cannot be combined/,
);
});
test('parseCliPrograms captures sync --ui', () => {
@@ -115,16 +91,3 @@ test('parseCliPrograms captures sync --ui', () => {
/--ui cannot be combined/,
);
});
test('parseCliPrograms rejects sync --ui with --remote-cmd', () => {
assert.throws(
() => parseCliPrograms(['sync', '--ui', '--remote-cmd', '/opt/SubMiner.AppImage'], 'subminer'),
{ message: 'Sync --ui cannot be combined with other sync options.' },
);
});
test('parseCliPrograms rejects sync --ui with --db', () => {
assert.throws(() => parseCliPrograms(['sync', '--ui', '--db', '/tmp/db.sqlite'], 'subminer'), {
message: 'Sync --ui cannot be combined with other sync options.',
});
});
+75 -23
View File
@@ -39,7 +39,17 @@ export interface CliInvocations {
statsCleanupLifetime: boolean;
statsLogLevel: string | null;
syncTriggered: boolean;
syncCliTokens: string[];
syncHost: string | null;
syncSnapshotPath: string | null;
syncMergePath: string | null;
syncDirection: 'both' | 'push' | 'pull';
syncRemoteCmd: string | null;
syncDbPath: string | null;
syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncMakeTemp: boolean;
syncRemoveTempPath: string | null;
syncLogLevel: string | null;
syncUiTriggered: boolean;
syncUiLogLevel: string | null;
@@ -171,7 +181,17 @@ export function parseCliPrograms(
let statsCleanupLifetime = false;
let statsLogLevel: string | null = null;
let syncTriggered = false;
let syncCliTokens: string[] = [];
let syncHost: string | null = null;
let syncSnapshotPath: string | null = null;
let syncMergePath: string | null = null;
let syncDirection: 'both' | 'push' | 'pull' = 'both';
let syncRemoteCmd: string | null = null;
let syncDbPath: string | null = null;
let syncForce = false;
let syncJson = false;
let syncCheck = false;
let syncMakeTemp = false;
let syncRemoveTempPath: string | null = null;
let syncLogLevel: string | null = null;
let syncUiTriggered = false;
let syncUiLogLevel: string | null = null;
@@ -340,8 +360,6 @@ export function parseCliPrograms(
check ||
makeTemp ||
removeTemp ||
options.remoteCmd !== undefined ||
options.db !== undefined ||
options.json === true ||
options.force === true
) {
@@ -351,25 +369,49 @@ export function parseCliPrograms(
syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
return;
}
// No validation here: the app's parseSyncCliTokens owns the sync rules
// and its error text reaches the terminal through the child's stdio.
const remoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() : '';
const dbPath = typeof options.db === 'string' ? options.db.trim() : '';
const tokens: string[] = [];
if (host) tokens.push(host);
if (snapshot) tokens.push('--snapshot', snapshot);
if (merge) tokens.push('--merge', merge);
if (makeTemp) tokens.push('--make-temp');
if (removeTemp) tokens.push('--remove-temp', removeTemp);
if (push) tokens.push('--push');
if (pull) tokens.push('--pull');
if (check) tokens.push('--check');
if (remoteCmd) tokens.push('--remote-cmd', remoteCmd);
if (dbPath) tokens.push('--db', dbPath);
if (options.force === true) tokens.push('--force');
if (options.json === true) tokens.push('--json');
if (push && pull) {
throw new Error('Sync --push and --pull cannot be combined.');
}
if ((push || pull) && !host) {
throw new Error('Sync --push and --pull require a host.');
}
if (check && !host) {
throw new Error('Sync --check requires a host.');
}
if (check && (push || pull || snapshot || merge)) {
throw new Error(
'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
);
}
if ((makeTemp || removeTemp) && (push || pull || check)) {
throw new Error('Sync --make-temp/--remove-temp cannot be combined with other sync options.');
}
const modes = [
Boolean(host),
Boolean(snapshot),
Boolean(merge),
makeTemp,
Boolean(removeTemp),
].filter(Boolean).length;
if (modes === 0) {
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
}
if (modes > 1) {
throw new Error('Sync host, --snapshot, --merge, --make-temp, and --remove-temp cannot be combined.');
}
syncTriggered = true;
syncCliTokens = tokens;
syncHost = host || null;
syncSnapshotPath = snapshot || null;
syncMergePath = merge || null;
syncDirection = push ? 'push' : pull ? 'pull' : 'both';
syncRemoteCmd =
typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
syncForce = options.force === true;
syncJson = options.json === true;
syncCheck = check;
syncMakeTemp = makeTemp;
syncRemoveTempPath = removeTemp || null;
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
@@ -485,7 +527,17 @@ export function parseCliPrograms(
statsCleanupLifetime,
statsLogLevel,
syncTriggered,
syncCliTokens,
syncHost,
syncSnapshotPath,
syncMergePath,
syncDirection,
syncRemoteCmd,
syncDbPath,
syncForce,
syncJson,
syncCheck,
syncMakeTemp,
syncRemoveTempPath,
syncLogLevel,
syncUiTriggered,
syncUiLogLevel,
+28 -2
View File
@@ -1,12 +1,38 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { resolveConfigDir } from '../src/config/path-resolution.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import type { HistoryVideoRow } from './history-types.js';
import { resolveImmersionDbPath } from '../src/core/services/stats-sync/db-path.js';
import { resolvePathMaybe } from './util.js';
import {
isReadonlyWalRetryError,
withReadonlyWalRetry,
} from '../src/core/services/stats-sync/wal-retry.js';
export { isReadonlyWalRetryError, resolveImmersionDbPath, withReadonlyWalRetry };
export { isReadonlyWalRetryError, withReadonlyWalRetry };
export function resolveImmersionDbPath(): string {
const root = readLauncherMainConfigObject();
const tracking =
root?.immersionTracking &&
typeof root.immersionTracking === 'object' &&
!Array.isArray(root.immersionTracking)
? (root.immersionTracking as Record<string, unknown>)
: null;
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
if (configured) return resolvePathMaybe(configured);
const configDir = resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
return path.join(configDir, 'immersion.sqlite');
}
interface RawHistoryRow {
video_id: number;
-43
View File
@@ -130,46 +130,3 @@ 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,7 +7,6 @@ import { Database } from 'bun:sqlite';
import {
detectImageExtension,
findNextEpisode,
findPreviousEpisode,
groupHistoryBySeries,
isReadonlyWalRetryError,
listSeasonDirs,
@@ -199,55 +198,6 @@ 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(
+11 -1
View File
@@ -31,7 +31,17 @@ function createArgs(): Args {
useRofi: false,
history: false,
sync: false,
syncCliTokens: [],
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncUi: false,
logLevel: 'info',
logRotation: 7,
+6 -8
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 { runHistorySession } from './commands/history-command.js';
import { runHistoryCommand } 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,15 +149,13 @@ async function main(): Promise<void> {
}
if (appContext.args.history) {
const played = await runHistorySession(appContext, async (videoPath) => {
appContext.args.target = videoPath;
appContext.args.targetKind = 'file';
await runPlaybackCommand(appContext);
});
if (!played) {
const selected = await runHistoryCommand(appContext);
if (!selected) {
log('info', args.logLevel, 'No watch history selection made, exiting');
return;
}
return;
appContext.args.target = selected;
appContext.args.targetKind = 'file';
}
await runPlaybackCommand(appContext);
+11 -32
View File
@@ -119,37 +119,6 @@ test('runAppCommandCaptureOutput transports Linux AppImage args through environm
}
});
test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'SubMiner.AppImage');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
'printf "args:%s\\n" "$*"',
'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"',
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
try {
const result = withPlatform('linux', () =>
runAppCommandCaptureOutput(appPath, ['--sync-cli', 'sync', '--snapshot', '/tmp/out']),
);
assert.equal(result.status, 0);
assert.match(result.stdout, /^args:-e /m);
assert.match(result.stdout, /^electron-node:1$/m);
assert.match(result.stdout, /^argc:4$/m);
assert.match(result.stdout, /^arg0:--sync-cli$/m);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('parseMpvArgString preserves empty quoted tokens', () => {
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
'--title',
@@ -603,7 +572,17 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
useRofi: false,
history: false,
sync: false,
syncCliTokens: [],
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncUi: false,
logLevel: 'error',
logRotation: 7,
-19
View File
@@ -1277,15 +1277,6 @@ function shouldTransportAppArgsForAppImage(appPath: string): boolean {
return process.platform === 'linux' && /\.AppImage$/i.test(appPath);
}
const APPIMAGE_SYNC_NODE_RUNNER = [
'const root=process.env.APPDIR+"/resources/app.asar";',
'const {runSyncCliFromProcess}=require(root+"/dist/main/sync-cli.js");',
'const count=Number(process.env.SUBMINER_APP_ARGC);',
'const argv=[process.execPath,...Array.from({length:count},(_,i)=>process.env["SUBMINER_APP_ARG_"+i]??"")];',
'runSyncCliFromProcess(argv,require(root+"/package.json").version)',
'.then(code=>process.exit(code),error=>{console.error(error);process.exit(1)});',
].join('');
function buildAppEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
extraEnv: NodeJS.ProcessEnv = {},
@@ -1441,16 +1432,6 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
if (shouldTransportAppArgsForAppImage(appPath)) {
if (appArgs[0] === '--sync-cli') {
return {
command: appPath,
args: ['-e', APPIMAGE_SYNC_NODE_RUNNER],
env: {
...buildTransportedAppArgsEnv(appArgs),
ELECTRON_RUN_AS_NODE: '1',
},
};
}
return {
command: appPath,
args: [],
+15 -13
View File
@@ -4,11 +4,17 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
// The engine executes on libsql in production; these merge tests run through
// that same driver. bun:sqlite is used only to build fixtures and inspect
// results.
import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js';
import { mergeSnapshotIntoDb } from '../../src/core/services/stats-sync/merge.js';
import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js';
import { createDbSnapshot as createDbSnapshotWith } from '../../src/core/services/stats-sync/shared.js';
import { mergeSnapshotIntoDb as mergeSnapshotIntoDbWith } from '../../src/core/services/stats-sync/merge.js';
// The engine only executes inside the app (libsql driver) in production, so
// these merge tests run through that same binding; bun:sqlite is used only to
// build fixtures and inspect results.
const createDbSnapshot = (dbPath: string, outPath: string) =>
createDbSnapshotWith(openLibsqlSyncDb, dbPath, outPath);
const mergeSnapshotIntoDb = (localDbPath: string, snapshotPath: string) =>
mergeSnapshotIntoDbWith(openLibsqlSyncDb, localDbPath, snapshotPath);
import {
createImmersionDbFixture,
insertFixtureSession,
@@ -632,10 +638,8 @@ 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,
);
@@ -652,10 +656,8 @@ 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 -2
View File
@@ -114,8 +114,17 @@ export interface Args {
useRofi: boolean;
history: boolean;
sync: boolean;
/** App-owned sync argv tokens forwarded verbatim to `--sync-cli sync`. */
syncCliTokens: string[];
syncHost: string;
syncSnapshotPath: string;
syncMergePath: string;
syncDirection: 'both' | 'push' | 'pull';
syncRemoteCmd: string;
syncDbPath: string;
syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncMakeTemp: boolean;
syncRemoveTempPath: string;
syncUi: boolean;
logLevel: LogLevel;
logRotation: LogRotation;
+21 -24
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.0-beta.2",
"version": "0.18.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -23,7 +23,7 @@
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs",
@@ -47,9 +47,13 @@
"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:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full",
"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: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",
@@ -60,13 +64,15 @@
"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: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: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": "bun run test:fast",
"test:config": "bun scripts/run-test-lane.mjs config",
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
"test:config": "bun run test:config:src",
"test:launcher": "bun run test:launcher:src",
"test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:src && bun run test:launcher:unit:src && bun run test:scripts",
"test:fast": "bun run test:src && bun run test:launcher:unit:src && bun run test:scripts && bun run test:runtime:compat",
"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",
@@ -81,18 +87,13 @@
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
},
"overrides": {
"@xmldom/xmldom": "0.8.13",
"@xmldom/xmldom": "0.8.12",
"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": "5.2.1",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7"
"tar": "7.5.11"
},
"keywords": [
"anki",
@@ -109,22 +110,21 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@xhayper/discord-rpc": "^1.3.4",
"axios": "^1.18.1",
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"hono": "^4.12.28",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"ws": "^8.21.0"
"ws": "^8.19.0"
},
"devDependencies": {
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron": "42.2.0",
"electron-builder": "26.8.2",
"undici": "7.28.0",
"esbuild": "^0.25.12",
"eslint": "^10.4.0",
"prettier": "^3.8.1",
@@ -259,8 +259,5 @@
"to": "launcher/subminer"
}
]
},
"patchedDependencies": {
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
}
}
-13
View File
@@ -1,13 +0,0 @@
diff --git a/package.json b/package.json
index 02d2d8809a98c5d32b889fceba11458660f1fa6a..72cd7004d92809689daaeec1cd8a274e1cabdff5 100644
--- a/package.json
+++ b/package.json
@@ -77,7 +77,7 @@
"discord-api-types": "^0.38.40",
"magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3",
- "undici": "6.24.1",
+ "undici": "6.27.0",
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.2.0"
},
-2
View File
@@ -254,8 +254,6 @@ function M.create(ctx)
return { "--open-runtime-options" }
elseif action_id == "openJimaku" then
return { "--open-jimaku" }
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
return { "--open-tsukihime" }
elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" }
elseif action_id == "openSessionHelp" then
+58 -26
View File
@@ -1,41 +1,73 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-base-version: 0.19.0 -->
<!-- prerelease-base-version: 0.18.0 -->
## Highlights
### Added
- **Sync Stats & History**
- New **Sync Stats & History** window (tray menu) and `subminer sync <host>` command keep mining stats and watch history in sync across machines over SSH.
- Syncing is safe to repeat: data merges without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
- Manual database snapshots (create, merge, reveal, delete) and connection testing cover one-off transfers, and Windows machines running the built-in OpenSSH Server work as sync remotes too; no setup beyond SSH access is needed on the remote side.
- **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.
- **Post-Playback History Menu**
- 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.
- **Watch History Browser**
- New `subminer -H` / `--history` command lets you browse your local watch history, replay the last episode, jump to the next one, or pick an episode via fzf or rofi.
- The rofi picker now shows AniList cover art for each show, making it easier to spot the right title at a glance.
- **Card Audio Normalization**
- Audio extracted for Anki cards is now volume-normalized by default, giving more consistent playback loudness across cards.
- Prefer the original source volume? Disable it via the new `ankiConnect.media.normalizeAudio` setting.
### Changed
- **Clipboard-Video Shortcut**: The "append clipboard video to queue" shortcut is now configurable (`shortcuts.appendClipboardVideoToQueue`) instead of fixed.
- **New App Icon**
- SubMiner now ships pixel-art submarine artwork contributed by an anonymous community member.
- Applied across the app icon, tray icon, notifications, README, docs site, and stats page.
- **Launcher Preview Layout**
- fzf previews in the launcher now sit below the menu instead of beside it, giving long titles and metadata more horizontal room.
### 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.
- **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.
- **Character Name Highlighting in Subtitles**
- Fixed unspaced Japanese names (e.g. 東紫乃, 渡辺真奈美) being split at the wrong point, which left surnames like 東 and 渡辺 without their character portrait or hover lookup.
- Fixed names getting cut off or losing their highlight when caught by the subtitle scanner's punctuation handling, misclassified by grammar tagging, or swallowed entirely by a longer generic dictionary match (e.g. ヨータ disappearing inside a false とヨー match).
- Fixed a single unrecognized word in a subtitle line (like a stray interjection) causing character-name highlighting to drop for the whole line instead of just that word.
- No action needed — existing data upgrades automatically the next time a matching name is seen.
- **Known-Word Highlighting**
- Words are no longer marked "known" (green) just because they share spelling with a known Anki card that actually teaches a different reading (e.g. 床 read as とこ no longer falsely matches a known 床/ゆか card).
- Kanji words are also no longer marked known just because a different mined word happens to share their reading (e.g. 渓谷/けいこく no longer falsely matches a known 警告/けいこく card).
- Single-kana grammar tokens (particles like よ, え) no longer borrow an unrelated card's reading and get falsely painted as known.
- Stats sessions now correctly reflect known-word counts again after the reading-aware matching upgrade, instead of showing 0 everywhere.
- **Annotation Highlighting Refinements**
- Restored frequency/JLPT highlighting and vocabulary-stat counting for words like 確かに and やはり, which were wrongly treated as grammar noise.
- Kanji nouns that MeCab tags as "non-independent" (e.g. 日, 点, 以外) also keep their highlighting and stats counting again.
- Suffix-only tokens (e.g. さん, れる) are now excluded from JLPT/frequency highlighting by default to match how particles and interjections are treated; known-word highlighting for them still works, and this is configurable.
- **Unparsed Subtitle Text**
- Subtitle text the dictionary can't recognize (like a truncated verb form) is now still hoverable for lookup and correctly counted toward a sentence's difficulty, instead of showing as dead, non-interactive text.
- **Kiku Manual Field Grouping**
- Fixed the field-grouping dialog getting stuck invisible behind fullscreen video on Hyprland/Wayland, and failing silently on repeated attempts after the first use.
- Fixed a timed-out or failed grouping request leaving an invisible, stuck dialog covering the video; it now closes automatically so the overlay recovers.
- Fixed a duplicate "Field grouping cancelled" notification appearing when grouping was cancelled via the trigger shortcut, and added a proper error message for the previously-silent case where the original card can no longer be loaded.
- **Secondary Subtitles**
- Karaoke-style secondary subtitles (common in opening/ending songs) no longer spam dozens of lines down the screen; repeated lines are now collapsed and the subtitle area is capped to a strip at the top.
- **YouTube Extraction**
- Fixed direct YouTube stream extraction occasionally corrupting the stream URL and causing failed audio/video capture.
- **Background Stats Server**
- Launching SubMiner in the background now correctly auto-starts the stats server when enabled, and won't start a duplicate if one's already running.
- **Stats Trend Charts**
- All trend chart titles now show by default, with the ability to hide specific titles (remembered across sessions) and cap how many top titles a chart displays.
- **Stats Cover Art**
- Cover art now loads as soon as a series starts playing instead of waiting for your first visit to its detail page, so the stats timeline shows artwork right away.
- Existing series missing art are backfilled automatically the next time you open the stats page.
## 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
- fix(youtube): parse mpv EDL stream URLs with byte-length guards by @ksyasuda in #134
- Normalize generated Anki audio by default by @ksyasuda in #135
- feat(launcher): add -H/--history command to browse local watch history by @ksyasuda in #136
- fix(overlay): prevent field grouping modal from freezing overlay on Hyprland by @ksyasuda in #138
- fix(overlay): collapse karaoke syllable spam in secondary subtitles by @ksyasuda in #139
- feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips by @ksyasuda in #140
- feat(branding): replace app icon with contributed pixel-art set by @ksyasuda in #141
- feat(anki): reading-aware known-word matching (cache v3) by @ksyasuda in #142
- fix(stats): start stats server on background app launch by @ksyasuda in #144
- fix(tokenizer): keep unparsed Yomitan tokens hoverable by @ksyasuda in #145
- fix(overlay): resolve unspaced Japanese name splits and scan recovery by @ksyasuda in #146
- fix(tokenizer): prevent grammar tokens from borrowing known-word highlight via unrelated readings by @ksyasuda in #147
- fix(stats): fetch cover art eagerly at session start instead of on series page visit by @ksyasuda in #148
- fix(overlay): keep frequency/JLPT highlight for kanji non-independent nouns by @ksyasuda in #150
- fix(tokenizer): greedy name pre-pass to prevent generic matches swallowing character names by @ksyasuda in #151
## Installation
-16
View File
@@ -1,16 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
) as { scripts: Record<string, string> };
const command = packageJson.scripts['build:syncui'] ?? '';
assert.match(command, /src\/preload-syncui\.ts/);
assert.match(command, /--bundle/);
assert.match(command, /--external:electron/);
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
});
+9
View File
@@ -16,6 +16,15 @@ 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'],
-9
View File
@@ -237,14 +237,6 @@ local ctx = {
actionType = "session-action",
actionId = "openPlaylistBrowser",
},
{
key = {
code = "KeyT",
modifiers = { "ctrl", "alt" },
},
actionType = "session-action",
actionId = "openAnimetosho",
},
{
key = {
code = "KeyH",
@@ -395,7 +387,6 @@ end
local expected_cli_bindings = {
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
{ keys = "Ctrl+Alt+t", flag = "--open-tsukihime" },
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
{ keys = "Ctrl+L", flag = "--play-next-subtitle" },
{ keys = "w", flag = "--mark-watched" },
-62
View File
@@ -1,7 +1,5 @@
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';
@@ -467,66 +465,6 @@ 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,7 +461,6 @@ 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,41 +543,3 @@ 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,7 +27,6 @@ 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 {
@@ -79,23 +78,7 @@ 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;
+18 -8
View File
@@ -12,6 +12,19 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>;
};
test('ci workflow lints changelog fragments', () => {
assert.match(ciWorkflow, /bun run changelog:lint/);
});
test('ci workflow checks pull requests for required changelog fragments', () => {
assert.match(ciWorkflow, /bun run changelog:pr-check/);
assert.match(ciWorkflow, /skip-changelog/);
});
test('ci workflow verifies generated config examples stay in sync', () => {
assert.match(ciWorkflow, /bun run verify:config-example/);
});
test('package scripts expose a sharded maintained source coverage lane with lcov output', () => {
assert.equal(
packageJson.scripts['test:coverage:src'],
@@ -19,14 +32,11 @@ test('package scripts expose a sharded maintained source coverage lane with lcov
);
});
test('ci delegates its gate instead of duplicating quality steps', () => {
assert.match(
ciWorkflow,
/build-test-audit:\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
);
assert.doesNotMatch(ciWorkflow, /oven-sh\/setup-bun/);
assert.doesNotMatch(ciWorkflow, /bun run test:coverage:src/);
assert.doesNotMatch(ciWorkflow, /bun run test:env/);
test('ci workflow runs the maintained source coverage lane and uploads lcov output', () => {
assert.match(ciWorkflow, /name: Coverage suite \(maintained source lane\)/);
assert.match(ciWorkflow, /run: bun run test:coverage:src/);
assert.match(ciWorkflow, /name: Upload coverage artifact/);
assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/);
});
test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => {
-10
View File
@@ -115,7 +115,6 @@ test('parseArgs captures session action forwarding flags', () => {
'--toggle-stats-overlay',
'--mark-watched',
'--open-jimaku',
'--open-tsukihime',
'--open-youtube-picker',
'--open-playlist-browser',
'--toggle-primary-subtitle-bar',
@@ -133,7 +132,6 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.toggleStatsOverlay, true);
assert.equal(args.markWatched, true);
assert.equal(args.openJimaku, true);
assert.equal(args.openTsukihime, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
assert.equal(args.togglePrimarySubtitleBar, true);
@@ -148,14 +146,6 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(shouldStartApp(args), true);
});
test('parseArgs keeps the legacy Animetosho open flag as a TsukiHime alias', () => {
const args = parseArgs(['--open-animetosho']);
assert.equal(args.openTsukihime, true);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
});
test('parseArgs ignores retired subtitle delay shift flags', () => {
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
+1 -10
View File
@@ -38,7 +38,6 @@ export interface CliArgs {
openControllerSelect: boolean;
openControllerDebug: boolean;
openJimaku: boolean;
openTsukihime: boolean;
openYoutubePicker: boolean;
openPlaylistBrowser: boolean;
replayCurrentSubtitle: boolean;
@@ -148,7 +147,6 @@ export function parseArgs(argv: string[]): CliArgs {
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openTsukihime: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
@@ -297,9 +295,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--open-controller-select') args.openControllerSelect = true;
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
else if (arg === '--open-jimaku') args.openJimaku = true;
else if (arg === '--open-tsukihime' || arg === '--open-animetosho') {
args.openTsukihime = true;
} else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
@@ -572,7 +568,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openTsukihime ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
@@ -651,7 +646,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
!args.openTsukihime &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
@@ -719,7 +713,6 @@ export function shouldStartApp(args: CliArgs): boolean {
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openTsukihime ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
@@ -781,7 +774,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
!args.openTsukihime &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
@@ -847,7 +839,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openTsukihime ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
+2 -14
View File
@@ -112,20 +112,8 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
if (!key) continue;
const valueNode = propertyValue(property);
const value = valueNode ? getNodeValue(valueNode) : undefined;
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);
}
if (key === 'enabled' || key === 'minSentenceWords') {
canonicalNPlusOneValues.set(key, value);
continue;
}
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
-31
View File
@@ -2462,37 +2462,6 @@ 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(
+2 -13
View File
@@ -37,18 +37,8 @@ const {
notifications,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const {
ankiConnect,
jimaku,
tsukihime,
anilist,
mpv,
yomitan,
jellyfin,
discordPresence,
ai,
youtubeSubgen,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
@@ -73,7 +63,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSidebar,
auto_start_overlay,
jimaku,
tsukihime,
anilist,
mpv,
yomitan,
-2
View File
@@ -98,13 +98,11 @@ export const CORE_DEFAULT_CONFIG: Pick<
openCharacterDictionaryManager: 'CommandOrControl+D',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openTsukihime: 'Ctrl+Shift+T',
openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: 'Backslash',
toggleNotificationHistory: 'CommandOrControl+N',
appendClipboardVideoToQueue: 'CommandOrControl+A',
},
secondarySub: {
secondarySubLanguages: [],
@@ -5,7 +5,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
| 'ankiConnect'
| 'jimaku'
| 'tsukihime'
| 'anilist'
| 'mpv'
| 'yomitan'
@@ -97,10 +96,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
languagePreference: 'ja',
maxEntryResults: 10,
},
tsukihime: {
apiBaseUrl: 'https://api.tsukihime.org/v1',
maxSearchResults: 10,
},
mpv: {
executablePath: '',
launchMode: 'normal',
-13
View File
@@ -615,13 +615,6 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.openJimaku,
description: 'Accelerator that opens the Jimaku subtitle search modal.',
},
{
path: 'shortcuts.openTsukihime',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openTsukihime,
description:
'Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).',
},
{
path: 'shortcuts.openSessionHelp',
kind: 'string',
@@ -653,11 +646,5 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.toggleNotificationHistory,
description: 'Accelerator that toggles the overlay notification history panel.',
},
{
path: 'shortcuts.appendClipboardVideoToQueue',
kind: 'string',
defaultValue: defaultConfig.shortcuts.appendClipboardVideoToQueue,
description: 'Accelerator that appends a video path from the clipboard to the mpv playlist.',
},
];
}
@@ -400,18 +400,6 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jimaku.maxEntryResults,
description: 'Maximum Jimaku search results returned.',
},
{
path: 'tsukihime.apiBaseUrl',
kind: 'string',
defaultValue: defaultConfig.tsukihime.apiBaseUrl,
description: 'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
},
{
path: 'tsukihime.maxSearchResults',
kind: 'number',
defaultValue: defaultConfig.tsukihime.maxSearchResults,
description: 'Maximum TsukiHime search results returned.',
},
{
path: 'anilist.enabled',
kind: 'boolean',
-3
View File
@@ -53,9 +53,6 @@ export const SPECIAL_COMMANDS = {
SUBSYNC_TRIGGER: '__subsync-trigger',
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
JIMAKU_OPEN: '__jimaku-open',
/** @deprecated Use TSUKIHIME_OPEN. */
ANIMETOSHO_OPEN: '__animetosho-open',
TSUKIHIME_OPEN: '__tsukihime-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
@@ -147,14 +147,6 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
key: 'jimaku',
},
{
title: 'TsukiHime',
description: [
'TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.',
],
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
key: 'tsukihime',
},
{
title: 'YouTube Playback Settings',
description: [
-119
View File
@@ -3,7 +3,6 @@ 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): {
@@ -40,90 +39,6 @@ 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 '],
@@ -262,40 +177,6 @@ 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',
+943 -16
View File
@@ -1,25 +1,952 @@
import { DEFAULT_CONFIG } from '../definitions';
import type { ResolveContext } from './context';
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';
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;
}
export function applyAnkiConnectResolution(context: ResolveContext): void {
if (!isObject(context.src.ankiConnect)) {
return;
}
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 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);
initializeAnkiConnectResolution(context, ankiConnect);
applyAnkiModernResolution(context, ankiConnect, behavior, media);
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context);
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;
}
}
-57
View File
@@ -1,57 +0,0 @@
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.',
);
}
}
}
@@ -1,81 +0,0 @@
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
@@ -1,19 +0,0 @@
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;
}
}
@@ -1,267 +0,0 @@
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
@@ -1,58 +0,0 @@
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
@@ -1,364 +0,0 @@
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.',
);
}
}
@@ -1,55 +0,0 @@
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'.",
);
}
@@ -1,24 +0,0 @@
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.',
);
}
}
@@ -1,145 +0,0 @@
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.',
);
}
}
@@ -1,22 +0,0 @@
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.',
);
}
@@ -1,46 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,70 +0,0 @@
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.',
);
}
}
@@ -1,9 +0,0 @@
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);
}

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