mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 04:49:49 -07:00
Compare commits
17 Commits
main
..
a4c12165af
| Author | SHA1 | Date | |
|---|---|---|---|
|
a4c12165af
|
|||
|
a013a7ea55
|
|||
|
f8c10edce0
|
|||
|
c9f85473bb
|
|||
|
25cca8ce24
|
|||
|
08419fbc8e
|
|||
|
94260bab16
|
|||
|
7ed4d4f8e2
|
|||
|
cd046b310a
|
|||
|
ffa183b1a1
|
|||
|
04095eebf7
|
|||
|
93d4bbe9a5
|
|||
|
cff164183a
|
|||
|
ac72c23dab
|
|||
|
187437b681
|
|||
|
97aaf44b3c
|
|||
|
0a3f76c0a8
|
@@ -8,4 +8,98 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-test-audit:
|
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
|
||||||
|
|||||||
@@ -12,9 +12,86 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality-gate:
|
quality-gate:
|
||||||
permissions:
|
runs-on: ubuntu-latest
|
||||||
contents: read
|
steps:
|
||||||
uses: ./.github/workflows/quality-gate.yml
|
- 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:
|
build-linux:
|
||||||
needs: [quality-gate]
|
needs: [quality-gate]
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -13,9 +13,76 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality-gate:
|
quality-gate:
|
||||||
permissions:
|
runs-on: ubuntu-latest
|
||||||
contents: read
|
steps:
|
||||||
uses: ./.github/workflows/quality-gate.yml
|
- 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:
|
build-linux:
|
||||||
needs: [quality-gate]
|
needs: [quality-gate]
|
||||||
|
|||||||
@@ -61,6 +61,3 @@ tests/*
|
|||||||
favicon.png
|
favicon.png
|
||||||
.claude/*
|
.claude/*
|
||||||
!stats/public/favicon.png
|
!stats/public/favicon.png
|
||||||
|
|
||||||
# Browser-automation session artifacts (page snapshots, console logs, downloads)
|
|
||||||
.playwright-mcp/
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
|
|||||||
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
|
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -90,14 +90,6 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
<td><b>Jimaku</b></td>
|
<td><b>Jimaku</b></td>
|
||||||
<td>Search and download Japanese subtitles</td>
|
<td>Search and download Japanese subtitles</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td><b>TsukiHime</b></td>
|
|
||||||
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><b>AniSkip</b></td>
|
|
||||||
<td>Automatic intro detection with chapter markers and a one-key skip (<code>TAB</code> by default)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>alass / ffsubsync</b></td>
|
<td><b>alass / ffsubsync</b></td>
|
||||||
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
||||||
@@ -118,19 +110,18 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
|
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
|
||||||
|
|
||||||
| Dependency | Status | What it does |
|
| Dependency | Status | What it does |
|
||||||
| -------------------- | ---------------- | -------------------------------------------------------- |
|
| -------------------- | ----------- | ---------------------------------------- |
|
||||||
| mpv | Required | The video player SubMiner overlays on |
|
| mpv | Required | The video player SubMiner overlays on |
|
||||||
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
|
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
|
||||||
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||||
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
||||||
| yt-dlp | Optional | YouTube playback |
|
| yt-dlp | Optional | YouTube playback |
|
||||||
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
|
| fzf / rofi | Optional | Video picker in the launcher |
|
||||||
| alass / ffsubsync | Optional | Subtitle sync |
|
| alass / ffsubsync | Optional | Subtitle sync |
|
||||||
| guessit | Optional | Better anime title and episode detection |
|
| guessit | Optional | Better anime title and episode detection |
|
||||||
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Platform-specific install commands</b></summary>
|
<summary><b>Platform-specific install commands</b></summary>
|
||||||
@@ -147,23 +138,9 @@ sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
|
|||||||
brew install mpv ffmpeg mecab mecab-ipadic
|
brew install mpv ffmpeg mecab mecab-ipadic
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows:**
|
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
|
||||||
|
|
||||||
```powershell
|
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
|
||||||
winget install shinchiro.mpv
|
|
||||||
winget install Gyan.FFmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
|
|
||||||
|
|
||||||
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
scoop bucket add extras
|
|
||||||
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -189,11 +166,6 @@ paru -S subminer-bin
|
|||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
||||||
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
||||||
```
|
|
||||||
|
|
||||||
The AppImage is all you need. The optional `subminer` command-line launcher runs on [Bun](https://bun.sh), and first-run setup can install both for you. To grab it manually instead, install Bun first, then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
||||||
&& chmod +x ~/.local/bin/subminer
|
&& chmod +x ~/.local/bin/subminer
|
||||||
```
|
```
|
||||||
@@ -241,7 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
|
|||||||
subminer video.mkv # launch mpv with SubMiner
|
subminer video.mkv # launch mpv with SubMiner
|
||||||
subminer /path/to/dir # pick a file with fzf
|
subminer /path/to/dir # pick a file with fzf
|
||||||
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
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.
|
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||||
|
|||||||
@@ -7,55 +7,48 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/geist-mono": "^5.2.7",
|
"@fontsource-variable/geist-mono": "^5.2.7",
|
||||||
"@xhayper/discord-rpc": "^1.3.4",
|
"@xhayper/discord-rpc": "^1.3.3",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.3",
|
||||||
"hono": "^4.12.28",
|
"hono": "^4.12.7",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"koffi": "^2.15.6",
|
"koffi": "^2.15.6",
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.19.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "42.2.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.8.2",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.4.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"undici": "7.28.0",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
|
||||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch",
|
|
||||||
},
|
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.12",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.8.2",
|
||||||
"brace-expansion": "5.0.8",
|
"electron-builder-squirrel-windows": "26.8.2",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
|
||||||
"form-data": "4.0.6",
|
|
||||||
"ip-address": "10.2.0",
|
|
||||||
"js-yaml": "4.3.0",
|
|
||||||
"lodash": "4.18.0",
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.3",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.11",
|
||||||
"tmp": "0.2.7",
|
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
|
||||||
|
|
||||||
|
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
||||||
|
|
||||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
||||||
@@ -66,7 +59,7 @@
|
|||||||
|
|
||||||
"@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="],
|
"@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="],
|
||||||
|
|
||||||
"@electron/rebuild": ["@electron/rebuild@4.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ=="],
|
"@electron/rebuild": ["@electron/rebuild@4.0.3", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA=="],
|
||||||
|
|
||||||
"@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="],
|
"@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="],
|
||||||
|
|
||||||
@@ -130,13 +123,13 @@
|
|||||||
|
|
||||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||||
|
|
||||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
|
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
|
||||||
|
|
||||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||||
|
|
||||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||||
|
|
||||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||||
|
|
||||||
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
|
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
|
||||||
|
|
||||||
@@ -152,6 +145,8 @@
|
|||||||
|
|
||||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||||
|
|
||||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||||
|
|
||||||
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="],
|
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="],
|
||||||
@@ -178,15 +173,11 @@
|
|||||||
|
|
||||||
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
||||||
|
|
||||||
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
"@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
|
||||||
|
|
||||||
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="],
|
"@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="],
|
||||||
|
|
||||||
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
|
|
||||||
|
|
||||||
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
|
|
||||||
|
|
||||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||||
|
|
||||||
@@ -216,35 +207,47 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||||
|
|
||||||
|
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
|
||||||
|
|
||||||
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
||||||
|
|
||||||
|
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
|
||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@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=="],
|
"@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@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
|
||||||
|
|
||||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="],
|
"app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="],
|
||||||
|
|
||||||
|
"app-builder-lib": ["app-builder-lib@26.8.2", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.2", "electron-builder-squirrel-windows": "26.8.2" } }, "sha512-z3ptLzJwNl35fyR0wxv4qWOfZuU36VysYHnbs8PDtf8S0QzIl2OWimdDFVmCxYMkIV1k/RT9CeTgcP7oUznFOw=="],
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
|
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
|
||||||
|
|
||||||
|
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
|
||||||
|
|
||||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||||
|
|
||||||
@@ -254,27 +257,29 @@
|
|||||||
|
|
||||||
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
|
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
|
||||||
|
|
||||||
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
|
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
"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=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"builder-util": ["builder-util@26.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "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-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="],
|
"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=="],
|
||||||
|
|
||||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
|
||||||
|
|
||||||
"bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="],
|
"cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="],
|
||||||
|
|
||||||
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
|
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
|
||||||
|
|
||||||
@@ -290,8 +295,16 @@
|
|||||||
|
|
||||||
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||||
|
|
||||||
|
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
|
||||||
|
|
||||||
|
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||||
|
|
||||||
|
"cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
|
||||||
|
|
||||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||||
|
|
||||||
|
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
|
||||||
|
|
||||||
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
|
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
@@ -306,6 +319,8 @@
|
|||||||
|
|
||||||
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||||
|
|
||||||
|
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
|
||||||
|
|
||||||
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
|
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
@@ -316,6 +331,8 @@
|
|||||||
|
|
||||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
|
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
|
||||||
|
|
||||||
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
||||||
|
|
||||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||||
@@ -330,9 +347,11 @@
|
|||||||
|
|
||||||
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
|
"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.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="],
|
"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=="],
|
||||||
|
|
||||||
|
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
|
|
||||||
@@ -340,24 +359,26 @@
|
|||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
|
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"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.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "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-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
|
"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=="],
|
||||||
|
|
||||||
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="],
|
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-kXhajX6DzdIQcTlctVTKoG1oO39JhWcTG0lH7ZEJ4FzPaKJy7KFNfNJUd5BoEmLjv5GlrRZpEOYnniD+LcwNJA=="],
|
||||||
|
|
||||||
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
|
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
|
||||||
|
|
||||||
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
|
"electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
|
||||||
|
|
||||||
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
|
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
|
||||||
|
|
||||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||||
@@ -380,7 +401,7 @@
|
|||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
"eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="],
|
"eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="],
|
||||||
|
|
||||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||||
|
|
||||||
@@ -398,13 +419,17 @@
|
|||||||
|
|
||||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
"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=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
"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=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
@@ -418,12 +443,16 @@
|
|||||||
|
|
||||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
"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=="],
|
||||||
|
|
||||||
"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=="],
|
"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.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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
|
||||||
|
|
||||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
@@ -458,9 +487,9 @@
|
|||||||
|
|
||||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
"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=="],
|
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||||
|
|
||||||
@@ -470,7 +499,13 @@
|
|||||||
|
|
||||||
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
|
"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=="],
|
||||||
|
|
||||||
|
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
@@ -480,23 +515,29 @@
|
|||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
"is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
|
||||||
|
|
||||||
|
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
|
||||||
|
|
||||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||||
|
|
||||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||||
|
|
||||||
|
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||||
|
|
||||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
"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=="],
|
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||||
|
|
||||||
@@ -530,12 +571,16 @@
|
|||||||
|
|
||||||
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
|
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
|
||||||
|
|
||||||
|
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
|
||||||
|
|
||||||
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
|
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||||
|
|
||||||
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
||||||
|
|
||||||
|
"make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="],
|
||||||
|
|
||||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
@@ -546,14 +591,26 @@
|
|||||||
|
|
||||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||||
|
|
||||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
|
"minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="],
|
||||||
|
|
||||||
|
"minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="],
|
||||||
|
|
||||||
|
"minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="],
|
||||||
|
|
||||||
|
"minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="],
|
||||||
|
|
||||||
|
"minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="],
|
||||||
|
|
||||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||||
|
|
||||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||||
@@ -562,15 +619,17 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
|
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
|
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
|
||||||
|
|
||||||
|
"node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||||
|
|
||||||
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
|
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
|
||||||
|
|
||||||
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
|
"node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="],
|
||||||
|
|
||||||
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
|
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
|
||||||
|
|
||||||
"nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],
|
|
||||||
|
|
||||||
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
|
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
|
||||||
|
|
||||||
@@ -578,28 +637,38 @@
|
|||||||
|
|
||||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||||
|
|
||||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|
||||||
|
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
|
||||||
|
|
||||||
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
||||||
|
|
||||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|
||||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
|
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
|
||||||
|
|
||||||
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
|
"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=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
"pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="],
|
|
||||||
|
|
||||||
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
||||||
|
|
||||||
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
||||||
@@ -608,9 +677,7 @@
|
|||||||
|
|
||||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||||
|
|
||||||
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
|
"proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
|
||||||
|
|
||||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
|
||||||
|
|
||||||
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||||
|
|
||||||
@@ -618,39 +685,37 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
|
||||||
|
|
||||||
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
|
|
||||||
|
|
||||||
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
|
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
|
||||||
|
|
||||||
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
|
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
|
||||||
|
|
||||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||||
|
|
||||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
|
||||||
|
|
||||||
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
|
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
|
||||||
|
|
||||||
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
|
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
|
||||||
|
|
||||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||||
|
|
||||||
|
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
|
||||||
|
|
||||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||||
|
|
||||||
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
||||||
|
|
||||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||||
|
|
||||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
|
"sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
|
||||||
|
|
||||||
@@ -670,25 +735,39 @@
|
|||||||
|
|
||||||
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
||||||
|
|
||||||
|
"slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||||
|
|
||||||
|
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||||
|
|
||||||
|
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
|
||||||
|
|
||||||
|
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||||
|
|
||||||
|
"ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="],
|
||||||
|
|
||||||
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
|
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
|
||||||
|
|
||||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
|
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
|
||||||
|
|
||||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
"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=="],
|
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
|
||||||
|
|
||||||
@@ -700,7 +779,7 @@
|
|||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"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=="],
|
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
|
||||||
|
|
||||||
@@ -714,13 +793,15 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"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=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
|
||||||
|
|
||||||
"unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="],
|
"unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="],
|
||||||
|
|
||||||
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
@@ -728,7 +809,9 @@
|
|||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
|
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||||
|
|
||||||
|
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
||||||
|
|
||||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||||
|
|
||||||
@@ -736,9 +819,11 @@
|
|||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"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=="],
|
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||||
|
|
||||||
@@ -750,13 +835,11 @@
|
|||||||
|
|
||||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
"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=="],
|
"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.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||||
|
|
||||||
"@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=="],
|
|
||||||
|
|
||||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||||
|
|
||||||
@@ -772,25 +855,37 @@
|
|||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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/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=="],
|
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"@types/fs-extra/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/fs-extra/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"@types/keyv/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/keyv/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
|
"@types/plist/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"@types/responselike/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/responselike/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/ws/@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=="],
|
"@types/yauzl/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
||||||
|
|
||||||
@@ -798,27 +893,29 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
|
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
|
|
||||||
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
|
||||||
|
|
||||||
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
|
||||||
|
|
||||||
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||||
|
|
||||||
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||||
|
|
||||||
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
|
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
@@ -826,27 +923,31 @@
|
|||||||
|
|
||||||
"@types/keyv/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"@types/keyv/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"@types/plist/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"@types/responselike/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"@types/responselike/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"@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/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/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=="],
|
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"app-builder-lib/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
"electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||||
|
|
||||||
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|
||||||
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
|
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
|
"minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. The session help color legend shows the four tier colors while maturity highlighting is on.
|
|
||||||
- Tiers follow Anki's own card counts: the interval tiers exclude cards in the learning/relearning queue, so a lapsed card shows the learning color instead of young (its interval is reset to at least 1 day, which previously made the learning tier unreachable). A note with a mature card alongside a relearning card still shows mature.
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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: character dictionary
|
|
||||||
|
|
||||||
- Kept manual AniList overrides active across episodes in the same season directory when filename guesses differ.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Stats reported 0 known words for every session after the known-word cache gained maturity tiers. The stats server carried its own copy of the cache parser that only recognized versions up to 3, so the new v4 file was read as "no cache" rather than as a format it should understand.
|
|
||||||
- The cache format, its parser, and the derived known-word set now live in one module that both the cache manager and the stats server read, and the version dispatch ends in an exhaustive check so a future format bump fails the build instead of silently reporting zero. A cache that exists but does not parse now logs a warning rather than passing for an empty one.
|
|
||||||
@@ -1,5 +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.
|
|
||||||
- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback.
|
|
||||||
@@ -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.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Consolidated renderer modal state handling into a descriptor registry.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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).
|
|
||||||
@@ -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.
|
|
||||||
@@ -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,7 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: tokenizer
|
|
||||||
|
|
||||||
- Added `verify-known-word-highlights:electron` script: tokenizes a real subtitle file through the app's Yomitan/MeCab pipeline with the live known-word cache, prints each line in the configured tier colors, and summarizes the tier counts so highlighting can be checked outside of playback.
|
|
||||||
- Added `--audit`, which re-derives every highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and reports each token whose rendered tier disagrees, catching both stale cache entries and tier-classification bugs.
|
|
||||||
- Added `--profile-copy` so the check can run while SubMiner is open (Electron locks the Yomitan userData dir), plus `--refresh`, `--limit`, `--json`, and `--quiet`.
|
|
||||||
- Added `KnownWordCacheManager.getKnownWordMatchNoteIds`, exposing the note ids behind a known-word match so an audit can trace a rendered tier back to the exact Anki notes.
|
|
||||||
+2
-22
@@ -205,13 +205,11 @@
|
|||||||
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
|
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search 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.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
"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.
|
"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.
|
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
|
||||||
"toggleNotificationHistory": "CommandOrControl+N", // Accelerator that toggles the overlay notification history panel.
|
"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.
|
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -433,12 +431,6 @@
|
|||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
"knownWordMaturityColors": {
|
|
||||||
"new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.
|
|
||||||
"learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.
|
|
||||||
"young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.
|
|
||||||
"mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.
|
|
||||||
}, // Known word maturity colors setting.
|
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -575,8 +567,6 @@
|
|||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
"maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false
|
|
||||||
"matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21).
|
|
||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||||
@@ -621,16 +611,6 @@
|
|||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // 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
|
// YouTube Playback Settings
|
||||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
@@ -691,7 +671,7 @@
|
|||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
|||||||
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
||||||
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0';
|
||||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||||
const versionLinkOrigin =
|
const versionLinkOrigin =
|
||||||
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
|
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
|
||||||
@@ -306,7 +306,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
|||||||
{ text: 'Usage', link: '/usage' },
|
{ text: 'Usage', link: '/usage' },
|
||||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||||
{ text: 'Feature Demos', link: '/demos' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -328,7 +327,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
|||||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||||
{ text: 'YouTube', link: '/youtube-integration' },
|
{ text: 'YouTube', link: '/youtube-integration' },
|
||||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||||
{ text: 'TsukiHime', link: '/tsukihime-integration' },
|
|
||||||
{ text: 'AniList', link: '/anilist-integration' },
|
{ text: 'AniList', link: '/anilist-integration' },
|
||||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
|
|||||||
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory defines the override scope, so later episodes in the same season directory keep the selected AniList ID even if their filename guesses differ. Separate season directories can keep separate overrides and character dictionaries. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
||||||
|
|
||||||
## Managing Loaded Entries
|
## Managing Loaded Entries
|
||||||
|
|
||||||
|
|||||||
+10
-50
@@ -71,10 +71,6 @@ When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
|
|||||||
|
|
||||||
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
||||||
|
|
||||||
::: warning One value in that file is platform-specific
|
|
||||||
The example is generated with a fixed Linux/macOS socket path so it stays reproducible, so it shows `"socketPath": "/tmp/subminer-socket"`. On Windows the real default is `\\\\.\\pipe\\subminer-socket`. Leave `mpv.socketPath` out of your config entirely unless you need a custom path, and SubMiner picks the right one for your platform.
|
|
||||||
:::
|
|
||||||
|
|
||||||
Generate a fresh default config from the centralized config registry:
|
Generate a fresh default config from the centralized config registry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -149,13 +145,12 @@ The configuration file includes several main sections:
|
|||||||
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
|
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
|
||||||
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
||||||
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
|
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
|
||||||
- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
|
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
|
||||||
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
|
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
|
||||||
|
|
||||||
**External Integrations**
|
**External Integrations**
|
||||||
|
|
||||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||||
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
|
|
||||||
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
|
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
|
||||||
@@ -412,7 +407,6 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||||
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
|
|
||||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||||
@@ -543,8 +537,6 @@ Display a second subtitle track (e.g., English alongside Japanese) in the overla
|
|||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
Secondary subtitles do **not** auto-load by default. To turn them on for local and Jellyfin playback, set `autoLoadSecondarySub` to `true` and list the language codes you want:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
@@ -555,15 +547,11 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
|
||||||
|
|
||||||
Because the mined-card translation field is filled from the secondary subtitle when one is present, leaving `autoLoadSecondarySub` off means local-file cards fall back to AI translation (when configured) or the original sentence text.
|
|
||||||
|
|
||||||
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||||
|
|
||||||
@@ -667,7 +655,6 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
"openJimaku": "Ctrl+Shift+J",
|
"openJimaku": "Ctrl+Shift+J",
|
||||||
"toggleSubtitleSidebar": "Backslash",
|
"toggleSubtitleSidebar": "Backslash",
|
||||||
"toggleNotificationHistory": "CommandOrControl+N",
|
"toggleNotificationHistory": "CommandOrControl+N",
|
||||||
"appendClipboardVideoToQueue": "CommandOrControl+A",
|
|
||||||
"multiCopyTimeoutMs": 3000
|
"multiCopyTimeoutMs": 3000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -694,7 +681,6 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
| `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. |
|
| `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). |
|
| `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.
|
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||||
|
|
||||||
@@ -834,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+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+D` | Open loaded character dictionary manager |
|
||||||
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
|
| `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:**
|
**Multi-line copy workflow:**
|
||||||
|
|
||||||
@@ -874,8 +860,8 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
|
|||||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||||
|
|
||||||
Current runtime options cover automatic card updates, known-word highlighting,
|
Current runtime options cover automatic card updates, known-word highlighting,
|
||||||
known-word maturity coloring, N+1 annotation, JLPT underlines, frequency
|
N+1 annotation, JLPT underlines, frequency highlighting, known-word match mode,
|
||||||
highlighting, known-word match mode, and Kiku field grouping mode.
|
and Kiku field grouping mode.
|
||||||
|
|
||||||
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||||
|
|
||||||
@@ -1042,8 +1028,6 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||||
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
|
||||||
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
|
||||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||||
@@ -1089,7 +1073,6 @@ Known-word cache policy:
|
|||||||
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||||
- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
|
- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
|
||||||
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
||||||
- Set `ankiConnect.knownWords.maturityEnabled` to `true` to color known words by Anki card maturity instead, using the four `subtitleStyle.knownWordMaturityColors` tiers. See [Known-Word Maturity Highlighting](/subtitle-annotations#known-word-maturity-highlighting) for how tiers are derived. Changing it or `matureThresholdDays` forces a full cache refresh.
|
|
||||||
- The known-word deck map accepts an object keyed by deck name.
|
- The known-word deck map accepts an object keyed by deck name.
|
||||||
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
|
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
|
||||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||||
@@ -1128,7 +1111,6 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
|
|||||||
|
|
||||||
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
||||||
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
||||||
<source :src="withBase('/assets/kiku-integration.mp4')" type="video/mp4" />
|
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
@@ -1154,28 +1136,6 @@ Configure Jimaku API access and defaults:
|
|||||||
|
|
||||||
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
||||||
|
|
||||||
### TsukiHime
|
|
||||||
|
|
||||||
TsukiHime subtitle search works out of the box and needs no account or API key. It does require the `xz` binary on your `PATH`, because TsukiHime serves extracted subtitles xz-compressed.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tsukihime": {
|
|
||||||
"apiBaseUrl": "https://api.tsukihime.org/v1",
|
|
||||||
"maxSearchResults": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
|
||||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
|
||||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
|
||||||
|
|
||||||
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
|
||||||
|
|
||||||
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
|
|
||||||
|
|
||||||
### Subtitle Sync
|
### Subtitle Sync
|
||||||
|
|
||||||
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||||
@@ -1528,7 +1488,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
"executablePath": "",
|
"executablePath": "",
|
||||||
"launchMode": "normal",
|
"launchMode": "normal",
|
||||||
"profile": "",
|
"profile": "",
|
||||||
"socketPath": "/tmp/subminer-socket",
|
"socketPath": "\\\\.\\pipe\\subminer-socket",
|
||||||
"backend": "auto",
|
"backend": "auto",
|
||||||
"autoStartSubMiner": true,
|
"autoStartSubMiner": true,
|
||||||
"pauseUntilOverlayReady": true,
|
"pauseUntilOverlayReady": true,
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
|
|||||||
</a>
|
</a>
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
|
::: info VIDEO COMING SOON
|
||||||
|
:::
|
||||||
|
|
||||||
## Subtitle Download & Sync
|
## Subtitle Download & Sync
|
||||||
|
|
||||||
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
|
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ Focused commands:
|
|||||||
bun run test:config # Source-level config schema/validation tests
|
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 # Launcher regression tests (config discovery + command routing)
|
||||||
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
|
bun run test:launcher: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:src # Bun-managed maintained src/** discovery lane
|
||||||
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
||||||
bun run test:scripts # Bun-managed scripts/** test lane
|
bun run test:scripts # Bun-managed scripts/** test lane
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ const ankiIntegrationContents = readFileSync(
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
||||||
const troubleshootingContents = readFileSync(
|
|
||||||
new URL('./troubleshooting.md', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
function extractReleaseHeadings(content: string, count: number): string[] {
|
function extractReleaseHeadings(content: string, count: number): string[] {
|
||||||
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
|
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
|
||||||
@@ -62,33 +58,6 @@ test('docs reflect current launcher and release surfaces', () => {
|
|||||||
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('docs document config surfaces that are easy to miss when they ship', () => {
|
|
||||||
// Anki maturity-based known-word highlighting (#172) landed in
|
|
||||||
// subtitle-annotations.md but was missing from the config reference.
|
|
||||||
expect(configurationContents).toContain('ankiConnect.knownWords.maturityEnabled');
|
|
||||||
expect(configurationContents).toContain('ankiConnect.knownWords.matureThresholdDays');
|
|
||||||
|
|
||||||
// Every top-level config block should be reachable from the config reference.
|
|
||||||
expect(configurationContents).toContain('### TsukiHime');
|
|
||||||
expect(configurationContents).toContain('tsukihime.maxSearchResults');
|
|
||||||
|
|
||||||
// xz is a hard runtime dependency of the TsukiHime download path.
|
|
||||||
expect(installationContents).toContain('xz');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('docs state the real secondary-subtitle and Anki field-matching behavior', () => {
|
|
||||||
// secondarySub auto-load is off by default; the config example previously
|
|
||||||
// implied otherwise while youtube-integration.md documented it correctly.
|
|
||||||
expect(configurationContents).toContain('Secondary subtitles do **not** auto-load by default');
|
|
||||||
expect(configurationContents).toContain('default: `false`');
|
|
||||||
|
|
||||||
// Anki field names are matched case-insensitively (src/anki-integration.ts
|
|
||||||
// resolveFieldName: exact match first, then a lowercase comparison).
|
|
||||||
expect(usageContents).not.toContain('exactly (case-sensitive)');
|
|
||||||
expect(troubleshootingContents).not.toContain('exactly (case-sensitive)');
|
|
||||||
expect(ankiIntegrationContents).toContain('case-insensitively');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('docs dev server links version navigation to local dev routes', () => {
|
test('docs dev server links version navigation to local dev routes', () => {
|
||||||
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
||||||
expect(docsPackageContents).toContain(
|
expect(docsPackageContents).toContain(
|
||||||
|
|||||||
+17
-109
@@ -12,23 +12,20 @@ Three steps to get started:
|
|||||||
|
|
||||||
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
||||||
|
|
||||||
Several entries below exist only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut instead, so you can ignore those rows.
|
| Dependency | Status | What it does |
|
||||||
|
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Dependency | Status | Platforms | What it does |
|
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||||
| -------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
||||||
| mpv | Required | All | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
||||||
| ffmpeg | Recommended | All | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
|
||||||
| MeCab + mecab-ipadic | Recommended | All | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
| fzf | Optional | Terminal-based video picker in the launcher. |
|
||||||
| yt-dlp | Optional | All | YouTube playback and subtitle extraction. |
|
| rofi | Optional | GUI-based video picker (Linux). |
|
||||||
| xz | Optional | All | Required for TsukiHime subtitle downloads (subtitles are served xz-compressed). Preinstalled on most Linux distros; not present on Windows by default. |
|
| chafa | Optional | Thumbnail previews in fzf. |
|
||||||
| guessit | Optional | All | Better AniSkip title/season/episode parsing. |
|
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
|
||||||
| alass | Optional | All | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
| guessit | Optional | Better AniSkip title/season/episode parsing. |
|
||||||
| ffsubsync | Optional | All | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
||||||
| fzf | Optional | Linux, macOS | Terminal-based video picker in the `subminer` launcher. |
|
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
||||||
| rofi | Optional | Linux | GUI-based video picker in the `subminer` launcher. |
|
| fuse2 | Linux only | Required to run the AppImage. |
|
||||||
| chafa | Optional | Linux, macOS | Thumbnail previews in the fzf picker. |
|
|
||||||
| ffmpegthumbnailer | Optional | Linux, macOS | Video thumbnail generation for the pickers. |
|
|
||||||
| fuse2 | Required | Linux | Needed to run the AppImage. |
|
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -112,98 +109,9 @@ pip install ffsubsync
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
|
Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
|
||||||
|
|
||||||
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`.
|
No compositor tools or window helpers are needed - native window tracking is built in.
|
||||||
|
|
||||||
::: tip What is PATH?
|
|
||||||
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner runs `mpv` and `ffmpeg` by name, so if their folders are not on `PATH`, SubMiner cannot find them even though they are installed. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
|
|
||||||
:::
|
|
||||||
|
|
||||||
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
|
|
||||||
|
|
||||||
| Dependency | winget | Scoop |
|
|
||||||
| ---------------- | --------------- | ------------- |
|
|
||||||
| mpv (required) | `shinchiro.mpv` | `extras/mpv` |
|
|
||||||
| ffmpeg | `Gyan.FFmpeg` | `main/ffmpeg` |
|
|
||||||
| yt-dlp (YouTube) | `yt-dlp.yt-dlp` | `main/yt-dlp` |
|
|
||||||
| xz (TsukiHime) | not packaged | `main/xz` |
|
|
||||||
|
|
||||||
Use **winget** if you want Microsoft's first-party tool and don't need TsukiHime subtitle downloads. Use **Scoop** if you want one package manager to cover everything, since it is the only one that also packages `xz`.
|
|
||||||
|
|
||||||
#### Recommended: winget
|
|
||||||
|
|
||||||
[winget](https://learn.microsoft.com/windows/package-manager/winget/) is Microsoft's own package manager and ships with Windows 11 and current Windows 10 (it comes with **App Installer** from the Microsoft Store). In **PowerShell** or **Command Prompt**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
winget install shinchiro.mpv
|
|
||||||
winget install Gyan.FFmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
Close and reopen your terminal, then check that both are found:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mpv --version
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
|
||||||
|
|
||||||
`ffmpeg` is installed as a portable package, so winget links it into a folder that is already on your `PATH` and it should work right away.
|
|
||||||
|
|
||||||
`mpv` uses a regular installer, and depending on the version it may **not** add itself to `PATH`. If `mpv --version` says `not recognized`, you have two easy options:
|
|
||||||
|
|
||||||
- Note where it installed (usually `%LOCALAPPDATA%\Programs\mpv`) and add that folder to `PATH` using the manual steps below, or
|
|
||||||
- Skip `PATH` entirely and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup.
|
|
||||||
|
|
||||||
Once `mpv --version` works, or you have the full path to `mpv.exe` ready, continue to [step 2](#_2-install-subminer).
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Alternative: Scoop (covers every dependency, no admin rights)</b></summary>
|
|
||||||
|
|
||||||
[Scoop](https://scoop.sh) installs into your user profile, needs no administrator prompt, and always puts commands on `PATH`. It is the only Windows package manager that carries all of SubMiner's optional dependencies, including `xz`, so it is the best choice if you want a single tool to manage everything.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# One-time Scoop setup (skip if you already have it)
|
|
||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
|
|
||||||
|
|
||||||
# mpv lives in the "extras" bucket; everything else is in "main"
|
|
||||||
scoop bucket add extras
|
|
||||||
scoop install extras/mpv main/ffmpeg
|
|
||||||
|
|
||||||
# Optional: yt-dlp for YouTube playback, xz for TsukiHime subtitle downloads
|
|
||||||
scoop install main/yt-dlp main/xz
|
|
||||||
```
|
|
||||||
|
|
||||||
Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -version`.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Manual install (download the zips yourself)</b></summary>
|
|
||||||
|
|
||||||
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
|
||||||
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
|
|
||||||
3. Press `Win`, type **Edit the system environment variables**, and open it. Click **Environment Variables…**, select **Path** under **User variables**, click **Edit…**, then use **New** to add each of those two folders. Confirm with **OK** on every dialog. Microsoft documents this in more detail under [environment variables](https://learn.microsoft.com/windows/deployment/usmt/usmt-recognized-environment-variables).
|
|
||||||
4. Close and reopen your terminal, since `PATH` changes only apply to newly opened windows. Then check:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mpv --version
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
|
||||||
|
|
||||||
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
|
|
||||||
|
|
||||||
::: tip mpv can skip PATH, ffmpeg cannot
|
|
||||||
If you would rather not edit `PATH` for mpv, set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup instead.
|
|
||||||
|
|
||||||
There is no equivalent setting for ffmpeg: SubMiner invokes it by bare name when generating card audio and screenshots, so ffmpeg has to be on `PATH`. Without it, cards are still created but their audio and image fields come out empty. (`subsync.ffmpeg_path` only affects subtitle sync, not card media.)
|
|
||||||
:::
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
**Optional extras:** [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary improves annotation accuracy; it is not in any package manager, so install it from that page. `xz` is needed only for [TsukiHime](/tsukihime-integration) subtitle downloads and is not packaged by winget or Chocolatey, so use `scoop install main/xz` or download [XZ Utils](https://tukaani.org/xz/) and add its folder to `PATH`.
|
|
||||||
|
|
||||||
The `subminer` command-line launcher and its picker tools (`fzf`, `rofi`, `chafa`, `ffmpegthumbnailer`) are Linux/macOS only; on Windows you use the **SubMiner mpv** shortcut instead.
|
|
||||||
|
|
||||||
## 2. Install SubMiner
|
## 2. Install SubMiner
|
||||||
|
|
||||||
@@ -370,7 +278,7 @@ Run the built-in diagnostic to confirm everything is working:
|
|||||||
subminer doctor
|
subminer doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
This checks for the app binary, mpv, ffmpeg, yt-dlp, fzf, rofi, your config file, and the mpv socket path. Only the app binary and mpv are hard failures; the rest are reported as optional. Fix any hard failures before continuing.
|
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
|
||||||
|
|
||||||
## Anki Setup (Recommended)
|
## Anki Setup (Recommended)
|
||||||
|
|
||||||
|
|||||||
@@ -112,5 +112,5 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
|
|||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Configuration Reference](/configuration#jimaku) - full config options
|
- [Configuration Reference](/configuration#jimaku) - full config options
|
||||||
- [Mining Workflow](/mining-workflow#related-features) - how Jimaku fits into the sentence mining loop
|
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) - how Jimaku fits into the sentence mining loop
|
||||||
- [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
|
- [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
|
||||||
|
|||||||
@@ -73,19 +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:
|
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:
|
||||||
|
|
||||||
- **Previous episode**: plays the episode before the last watched one and continues into the previous season directory when the season starts
|
- **Replay last watched** — replays the most recently watched episode
|
||||||
- **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)
|
||||||
- **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 is shown first
|
||||||
- **Browse episodes**: lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu appears first
|
|
||||||
- **Quit SubMiner**: closes the history session without starting an episode
|
|
||||||
|
|
||||||
After an episode ends or you close mpv, the launcher returns to an action menu for the same series. The menu lists Previous, Rewatch, Next, Select episode, and Quit SubMiner in that order, omitting Previous or Next when no episode exists in that direction. Choosing Previous or Next can move between season directories. After you play another episode, Previous, Rewatch, and Next use it instead of the older database entry. Pressing Escape closes the history session.
|
|
||||||
|
|
||||||
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
|
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
|
## 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
|
```bash
|
||||||
subminer sync macbook # two-way sync with the host "macbook"
|
subminer sync macbook # two-way sync with the host "macbook"
|
||||||
@@ -97,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)
|
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.
|
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).
|
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).
|
||||||
|
|
||||||
@@ -114,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
|
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).
|
`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
|
### 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Common Commands
|
||||||
|
|
||||||
@@ -176,22 +172,22 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
| --------------------- | ---------------------------------------------------------------------------- |
|
| --------------------- | --------------------------------------------------------------------------- |
|
||||||
| `-d, --directory` | Video search directory (default: cwd) |
|
| `-d, --directory` | Video search directory (default: cwd) |
|
||||||
| `-r, --recursive` | Search directories recursively |
|
| `-r, --recursive` | Search directories recursively |
|
||||||
| `-R, --rofi` | Use rofi instead of fzf |
|
| `-R, --rofi` | Use rofi instead of fzf |
|
||||||
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
||||||
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
|
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
|
||||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||||
| `--start` | Explicitly start overlay after mpv launches |
|
| `--start` | Explicitly start overlay after mpv launches |
|
||||||
| `-S, --start-overlay` | Force the visible overlay on start |
|
| `-S, --start-overlay` | Force the visible overlay on start |
|
||||||
| `-T, --no-texthooker` | Disable texthooker server |
|
| `-T, --no-texthooker` | Disable texthooker server |
|
||||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||||
| `-b, --backend` | Force window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||||
| `--settings` | Open the SubMiner settings window |
|
| `--settings` | Open the SubMiner settings window |
|
||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
|
|
||||||
App-binary flags such as `--setup`, `--dev`, and `--debug` are not launcher flags - pass them through with `subminer app`, for example `subminer app --setup`.
|
App-binary flags such as `--setup`, `--dev`, and `--debug` are not launcher flags - pass them through with `subminer app`, for example `subminer app --setup`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { expect, test } from 'bun:test';
|
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
|
|
||||||
|
|
||||||
// Mirrors VitePress' heading slugifier (vitepress/dist/node, `rControl` + `rSpecial`).
|
|
||||||
// Note that a *run* of special characters collapses to a single `-`, so
|
|
||||||
// "KDE Plasma & other" becomes "kde-plasma-other", not "kde-plasma--other".
|
|
||||||
const rControl = new RegExp('[\\u0000-\\u001f]', 'g');
|
|
||||||
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
|
|
||||||
|
|
||||||
function slugify(heading: string): string {
|
|
||||||
return heading
|
|
||||||
.replace(rControl, '')
|
|
||||||
.replace(rSpecial, '-')
|
|
||||||
.replace(/-{2,}/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.replace(/^(\d)/, '_$1')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXCLUDED_PAGES = new Set(['README.md']);
|
|
||||||
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
|
|
||||||
|
|
||||||
function loadPages(): Map<string, string> {
|
|
||||||
const pages = new Map<string, string>();
|
|
||||||
for (const file of readdirSync(docsSiteDir)) {
|
|
||||||
if (!file.endsWith('.md') || EXCLUDED_PAGES.has(file)) continue;
|
|
||||||
const route = `/${file.replace(/\.md$/, '')}`;
|
|
||||||
pages.set(route, readFileSync(`${docsSiteDir}${file}`, 'utf8'));
|
|
||||||
}
|
|
||||||
return pages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function anchorsFor(contents: string): Set<string> {
|
|
||||||
const anchors = new Set<string>();
|
|
||||||
for (const match of contents.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) {
|
|
||||||
let heading = match[1]!;
|
|
||||||
const explicitId = heading.match(/\{#([^}]+)\}\s*$/);
|
|
||||||
if (explicitId) {
|
|
||||||
anchors.add(explicitId[1]!);
|
|
||||||
heading = heading.replace(/\{#[^}]+\}\s*$/, '');
|
|
||||||
}
|
|
||||||
anchors.add(slugify(heading.replace(/`/g, '')));
|
|
||||||
}
|
|
||||||
return anchors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRoute(target: string, fromRoute: string): string {
|
|
||||||
if (target === '') return fromRoute;
|
|
||||||
if (target.startsWith('./')) return `/${target.slice(2).replace(/\.md$/, '')}`;
|
|
||||||
const normalized = target.replace(/\.md$/, '').replace(/\/$/, '');
|
|
||||||
return normalized === '' ? '/index' : normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = loadPages();
|
|
||||||
const anchors = new Map([...pages].map(([route, body]) => [route, anchorsFor(body)]));
|
|
||||||
|
|
||||||
test('every internal docs link resolves to an existing page', () => {
|
|
||||||
const broken: string[] = [];
|
|
||||||
|
|
||||||
for (const [route, body] of pages) {
|
|
||||||
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*)\)/g)) {
|
|
||||||
const link = match[1]!;
|
|
||||||
const target = link.split('#')[0]!;
|
|
||||||
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
|
|
||||||
|
|
||||||
const resolved = resolveRoute(target, route);
|
|
||||||
if (resolved !== '/index' && !pages.has(resolved)) {
|
|
||||||
broken.push(`${route.slice(1)}.md -> ${link}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(broken).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every internal docs anchor matches a real heading slug', () => {
|
|
||||||
const broken: string[] = [];
|
|
||||||
|
|
||||||
for (const [route, body] of pages) {
|
|
||||||
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*|#[^)\s]*)\)/g)) {
|
|
||||||
const link = match[1]!;
|
|
||||||
const hashIndex = link.indexOf('#');
|
|
||||||
if (hashIndex < 0) continue;
|
|
||||||
|
|
||||||
const target = link.slice(0, hashIndex);
|
|
||||||
const anchor = link.slice(hashIndex + 1);
|
|
||||||
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
|
|
||||||
|
|
||||||
const resolved = resolveRoute(target, route);
|
|
||||||
const pageAnchors = anchors.get(resolved);
|
|
||||||
if (!pageAnchors || pageAnchors.has(anchor)) continue;
|
|
||||||
|
|
||||||
broken.push(`${route.slice(1)}.md -> ${link}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(broken).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('slugify matches the VitePress cases these docs actually rely on', () => {
|
|
||||||
// Regression guards for the anchors that were previously wrong.
|
|
||||||
expect(slugify('N+1 Word Highlighting')).toBe('n-1-word-highlighting');
|
|
||||||
expect(slugify('KDE Plasma & other Wayland compositors')).toBe(
|
|
||||||
'kde-plasma-other-wayland-compositors',
|
|
||||||
);
|
|
||||||
expect(slugify('Proxy Mode Setup (Yomitan / Texthooker)')).toBe(
|
|
||||||
'proxy-mode-setup-yomitan-texthooker',
|
|
||||||
);
|
|
||||||
expect(slugify('Kiku/Lapis Integration')).toBe('kiku-lapis-integration');
|
|
||||||
expect(slugify('Secondary Subtitles')).toBe('secondary-subtitles');
|
|
||||||
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every docs page is reachable from the sidebar', async () => {
|
|
||||||
const { default: config } = await import('./.vitepress/config');
|
|
||||||
const sidebar = config.themeConfig?.sidebar as Array<{
|
|
||||||
items?: Array<{ text: string; link?: string }>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const linked = new Set<string>();
|
|
||||||
for (const group of sidebar) {
|
|
||||||
for (const item of group.items ?? []) {
|
|
||||||
if (item.link) linked.add(item.link === '/' ? '/index' : item.link);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const orphans = [...pages.keys()].filter((route) => !linked.has(route));
|
|
||||||
expect(orphans).toEqual([]);
|
|
||||||
});
|
|
||||||
@@ -183,7 +183,7 @@ If you want to build your own browser client, websocket consumer, or automation
|
|||||||
These features support the mining loop but have their own dedicated pages:
|
These features support the mining loop but have their own dedicated pages:
|
||||||
|
|
||||||
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
|
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
|
||||||
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
- **[N+1 word highlighting](/subtitle-annotations#n1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
||||||
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
|
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
|
||||||
|
|
||||||
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
|
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
||||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
||||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
"@catppuccin/vitepress": "^0.1.2",
|
||||||
|
|||||||
@@ -205,13 +205,11 @@
|
|||||||
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
|
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search 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.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
"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.
|
"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.
|
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
|
||||||
"toggleNotificationHistory": "CommandOrControl+N", // Accelerator that toggles the overlay notification history panel.
|
"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.
|
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -433,12 +431,6 @@
|
|||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
"knownWordMaturityColors": {
|
|
||||||
"new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.
|
|
||||||
"learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.
|
|
||||||
"young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.
|
|
||||||
"mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.
|
|
||||||
}, // Known word maturity colors setting.
|
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -575,8 +567,6 @@
|
|||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
"maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false
|
|
||||||
"matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21).
|
|
||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||||
@@ -621,16 +611,6 @@
|
|||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // 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
|
// YouTube Playback Settings
|
||||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
@@ -691,7 +671,7 @@
|
|||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
|||||||
@@ -251,9 +251,7 @@ test('dev docs version links use local targets for version route testing', async
|
|||||||
delete process.env.SUBMINER_DOCS_CHANNEL;
|
delete process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
delete process.env.SUBMINER_DOCS_BASE;
|
delete process.env.SUBMINER_DOCS_BASE;
|
||||||
delete process.env.SUBMINER_DOCS_VERSION;
|
delete process.env.SUBMINER_DOCS_VERSION;
|
||||||
// Set explicitly (like the sibling version-nav tests) so this assertion stays
|
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||||
// pinned to the manifest under test instead of the config's fallback constant.
|
|
||||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
|
||||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||||
latestStable: 'v0.14.0',
|
latestStable: 'v0.14.0',
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
| `Ctrl+W` | Quit mpv |
|
| `Ctrl+W` | Quit mpv |
|
||||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||||
| `Right-click + drag` | Reposition subtitles (on 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.
|
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+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `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/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `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 subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
|
||||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||||
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
|
| `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 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.
|
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
|
||||||
|
|||||||
@@ -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.
|
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 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.
|
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:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ----------------------------------------- | ------------ | -------------------------------------------------------- |
|
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
|
| `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.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
|
||||||
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
|
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
||||||
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
|
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
| `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.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
|
||||||
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
|
| `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.
|
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.
|
||||||
|
|
||||||
@@ -43,44 +41,6 @@ Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only f
|
|||||||
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Known-Word Maturity Highlighting
|
|
||||||
|
|
||||||
Instead of one color for every known word, maturity highlighting tints each known token by the review state of its Anki cards (like asbplayer), giving an at-a-glance sense of how much of a line is solidly learned.
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
|
|
||||||
1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`) - no extra card data is downloaded.
|
|
||||||
2. Each note gets the tier of its **most mature** card: `mature` (in review, interval ≥ threshold), `young` (in review, interval below the threshold), `learning` (in the learning or relearning queue), or `new` (never studied). The buckets are disjoint, matching Anki's own card counts: a lapsed card in relearning counts as `learning`, not `young`, even though its interval is ≥ 1 day. A note with a mature card plus a relearning card still shows `mature`.
|
|
||||||
3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting.
|
|
||||||
4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color.
|
|
||||||
|
|
||||||
**Key settings:**
|
|
||||||
|
|
||||||
| Option | Default | Description |
|
|
||||||
| ------------------------------------------------ | --------- | --------------------------------------------------------------------- |
|
|
||||||
| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) |
|
|
||||||
| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature |
|
|
||||||
| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards |
|
|
||||||
| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for cards in the learning/relearning queue |
|
|
||||||
| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards |
|
|
||||||
| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards |
|
|
||||||
|
|
||||||
Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched, as does upgrading to a build that revises the tier rules.
|
|
||||||
|
|
||||||
How often the `learning` color appears depends on your deck preset: with no relearning steps configured, a lapsed card returns straight to review and shows `young` instead.
|
|
||||||
|
|
||||||
While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature).
|
|
||||||
|
|
||||||
**Checking the colors you actually see:**
|
|
||||||
|
|
||||||
Tiers are only as fresh as the last known-word cache refresh (`ankiConnect.knownWords.refreshMinutes`), so a card that crosses the mature threshold mid-day keeps its old color until the next refresh. To check a whole episode offline, run the verifier against its subtitle file:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun run verify-known-word-highlights:electron -- --input /path/to/episode.ja.srt --audit
|
|
||||||
```
|
|
||||||
|
|
||||||
It tokenizes every cue through the real Yomitan/MeCab pipeline with your live known-word cache, prints each line in your configured tier colors, and summarizes the tier counts. `--audit` re-derives each highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and lists any token whose color disagrees, with the note ids and intervals behind it. Electron locks the Yomitan profile, so quit SubMiner first or pass `--profile-copy` to run against a scratch copy. Other useful flags: `--refresh` (refresh the cache first), `--limit <n>`, `--quiet`, `--json`.
|
|
||||||
|
|
||||||
## Character-Name Highlighting
|
## Character-Name Highlighting
|
||||||
|
|
||||||
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail.
|
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail.
|
||||||
@@ -169,7 +129,6 @@ All colors are customizable via the `subtitleStyle.jlptColors` object.
|
|||||||
These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting:
|
These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting:
|
||||||
|
|
||||||
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
|
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
|
||||||
- `ankiConnect.knownWords.maturityEnabled` (`On` / `Off`)
|
|
||||||
- `ankiConnect.knownWords.matchMode`
|
- `ankiConnect.knownWords.matchMode`
|
||||||
- `ankiConnect.nPlusOne.enabled` (`On` / `Off`)
|
- `ankiConnect.nPlusOne.enabled` (`On` / `Off`)
|
||||||
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
||||||
@@ -185,6 +144,6 @@ When multiple annotations apply to the same token, the visual priority is:
|
|||||||
|
|
||||||
1. **Character-name match** (highest) - dictionary-driven character-name token styling; it clears the token's N+1, frequency, and JLPT annotations
|
1. **Character-name match** (highest) - dictionary-driven character-name token styling; it clears the token's N+1, frequency, and JLPT annotations
|
||||||
2. **N+1 target** - the single unknown word in an N+1 sentence
|
2. **N+1 target** - the single unknown word in an N+1 sentence
|
||||||
3. **Known-word color** - already-learned token tint (per-tier maturity colors when `maturityEnabled` is on)
|
3. **Known-word color** - already-learned token tint
|
||||||
4. **Frequency highlight** - common-word coloring (not applied when a higher layer already matched)
|
4. **Frequency highlight** - common-word coloring (not applied when a higher layer already matched)
|
||||||
5. **JLPT underline** - level-based underline (stacks with N+1/known/frequency since it uses underline rather than text color, but not with a character-name match)
|
5. **JLPT underline** - level-based underline (stacks with N+1/known/frequency since it uses underline rather than text color, but not with a character-name match)
|
||||||
|
|||||||
+87
-104
@@ -16,6 +16,90 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
|
|||||||
|
|
||||||
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
|
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
|
||||||
|
|
||||||
|
## Logging and App Mode
|
||||||
|
|
||||||
|
- Default log output is `warn`.
|
||||||
|
- Use `--log-level` for more/less output.
|
||||||
|
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
||||||
|
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
||||||
|
|
||||||
|
## Performance and Resource Impact
|
||||||
|
|
||||||
|
### At a glance
|
||||||
|
|
||||||
|
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
||||||
|
- Common spikes come from:
|
||||||
|
- first subtitle parse/tokenization bursts
|
||||||
|
- media generation (`ffmpeg` audio/image and AVIF paths)
|
||||||
|
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
||||||
|
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
||||||
|
|
||||||
|
### If playback feels sluggish
|
||||||
|
|
||||||
|
1. Reduce overlay workload:
|
||||||
|
|
||||||
|
- set secondary subtitles hidden:
|
||||||
|
- `secondarySub.defaultMode: "hidden"`
|
||||||
|
- disable optional enrichment:
|
||||||
|
- `subtitleStyle.enableJlpt: false`
|
||||||
|
- `subtitleStyle.frequencyDictionary.enabled: false`
|
||||||
|
|
||||||
|
2. Reduce rendering pressure:
|
||||||
|
|
||||||
|
- lower `subtitleStyle.css["font-size"]`
|
||||||
|
- keep overlay complexity minimal during heavy CPU periods
|
||||||
|
|
||||||
|
3. Reduce media overhead:
|
||||||
|
|
||||||
|
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
||||||
|
- lower `ankiConnect.media.imageQuality`
|
||||||
|
- reduce `ankiConnect.media.maxMediaDuration`
|
||||||
|
|
||||||
|
4. Lower integration cost:
|
||||||
|
|
||||||
|
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
||||||
|
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
||||||
|
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
|
||||||
|
|
||||||
|
### Practical low-impact profile
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"css": {
|
||||||
|
"font-size": "30px"
|
||||||
|
},
|
||||||
|
"enableJlpt": false,
|
||||||
|
"frequencyDictionary": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"secondarySub": {
|
||||||
|
"defaultMode": "hidden"
|
||||||
|
},
|
||||||
|
"ankiConnect": {
|
||||||
|
"media": {
|
||||||
|
"imageType": "static",
|
||||||
|
"imageQuality": 80,
|
||||||
|
"maxMediaDuration": 12
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"immersionTracking": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### If usage is still high
|
||||||
|
|
||||||
|
- Confirm only one SubMiner instance is running.
|
||||||
|
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
|
||||||
|
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
|
||||||
|
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
||||||
|
|
||||||
**"Failed to parse MPV message"**
|
**"Failed to parse MPV message"**
|
||||||
|
|
||||||
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
|
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
|
||||||
@@ -64,7 +148,7 @@ SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated er
|
|||||||
|
|
||||||
**Cards are created but fields are empty**
|
**Cards are created but fields are empty**
|
||||||
|
|
||||||
Field names in your config must name a field that exists on your Anki note type. Matching is case-insensitive (`sentenceaudio` finds `SentenceAudio`), but the spelling must otherwise match, and unknown fields are skipped silently. Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
|
Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
|
||||||
|
|
||||||
See [Anki Integration](/anki-integration) for the full field mapping reference.
|
See [Anki Integration](/anki-integration) for the full field mapping reference.
|
||||||
|
|
||||||
@@ -232,115 +316,17 @@ If subtitle sync fails (the error message is prefixed with the engine name):
|
|||||||
- Try running the sync tool manually to see detailed error output.
|
- Try running the sync tool manually to see detailed error output.
|
||||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||||
|
|
||||||
## TsukiHime
|
|
||||||
|
|
||||||
**"xz binary not found"**
|
|
||||||
|
|
||||||
TsukiHime serves extracted subtitles xz-compressed, so SubMiner shells out to `xz` to decompress them. Install it:
|
|
||||||
|
|
||||||
- **Arch Linux**: `sudo pacman -S xz`
|
|
||||||
- **Ubuntu/Debian**: `sudo apt install xz-utils`
|
|
||||||
- **Fedora**: `sudo dnf install xz`
|
|
||||||
- **macOS**: `brew install xz`
|
|
||||||
- **Windows**: neither winget nor Chocolatey packages `xz`. Use `scoop install main/xz`, or download XZ Utils from [tukaani.org/xz](https://tukaani.org/xz/) and add the folder containing `xz.exe` to your `PATH`. Restart SubMiner afterwards.
|
|
||||||
|
|
||||||
Most Linux distributions ship it already. See [TsukiHime Integration](/tsukihime-integration#troubleshooting) for the other TsukiHime error messages.
|
|
||||||
|
|
||||||
## Jimaku
|
## Jimaku
|
||||||
|
|
||||||
**"Jimaku request failed" or HTTP 429**
|
**"Jimaku request failed" or HTTP 429**
|
||||||
|
|
||||||
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
|
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
|
||||||
|
|
||||||
## Logging and App Mode
|
|
||||||
|
|
||||||
- Default log output is `warn`.
|
|
||||||
- Use `--log-level` for more/less output.
|
|
||||||
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
|
||||||
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
|
||||||
|
|
||||||
## Performance and Resource Impact
|
|
||||||
|
|
||||||
### At a glance
|
|
||||||
|
|
||||||
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
|
||||||
- Common spikes come from:
|
|
||||||
- first subtitle parse/tokenization bursts
|
|
||||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
|
||||||
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
|
||||||
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
|
||||||
|
|
||||||
### If playback feels sluggish
|
|
||||||
|
|
||||||
1. Reduce overlay workload:
|
|
||||||
|
|
||||||
- set secondary subtitles hidden:
|
|
||||||
- `secondarySub.defaultMode: "hidden"`
|
|
||||||
- disable optional enrichment:
|
|
||||||
- `subtitleStyle.enableJlpt: false`
|
|
||||||
- `subtitleStyle.frequencyDictionary.enabled: false`
|
|
||||||
|
|
||||||
2. Reduce rendering pressure:
|
|
||||||
|
|
||||||
- lower `subtitleStyle.css["font-size"]`
|
|
||||||
- keep overlay complexity minimal during heavy CPU periods
|
|
||||||
|
|
||||||
3. Reduce media overhead:
|
|
||||||
|
|
||||||
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
|
||||||
- lower `ankiConnect.media.imageQuality`
|
|
||||||
- reduce `ankiConnect.media.maxMediaDuration`
|
|
||||||
|
|
||||||
4. Lower integration cost:
|
|
||||||
|
|
||||||
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
|
||||||
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
|
||||||
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
|
|
||||||
|
|
||||||
### Practical low-impact profile
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"subtitleStyle": {
|
|
||||||
"css": {
|
|
||||||
"font-size": "30px"
|
|
||||||
},
|
|
||||||
"enableJlpt": false,
|
|
||||||
"frequencyDictionary": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secondarySub": {
|
|
||||||
"defaultMode": "hidden"
|
|
||||||
},
|
|
||||||
"ankiConnect": {
|
|
||||||
"media": {
|
|
||||||
"imageType": "static",
|
|
||||||
"imageQuality": 80,
|
|
||||||
"maxMediaDuration": 12
|
|
||||||
},
|
|
||||||
"ai": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"immersionTracking": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### If usage is still high
|
|
||||||
|
|
||||||
- Confirm only one SubMiner instance is running.
|
|
||||||
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
|
|
||||||
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
|
|
||||||
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
|
||||||
|
|
||||||
## Platform-Specific
|
## Platform-Specific
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-other-wayland-compositors)).
|
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma--other-wayland-compositors)).
|
||||||
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
|
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
|
||||||
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
|
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
|
||||||
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
|
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
|
||||||
@@ -442,11 +428,8 @@ Feature-specific issues are covered in each feature's own page:
|
|||||||
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
|
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
|
||||||
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
|
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
|
||||||
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
|
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
|
||||||
- [TsukiHime Integration](/tsukihime-integration) - multi-language subtitle download and `xz` decompression
|
|
||||||
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
|
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
|
||||||
- [Immersion Tracking](/immersion-tracking) - telemetry, session logging, and the stats dashboard
|
- [Immersion Tracking](/immersion-tracking) - telemetry and session logging
|
||||||
- [Launcher Script](/launcher-script) - `subminer` commands, pickers, watch history, and cross-machine sync
|
|
||||||
- [MPV Plugin](/mpv-plugin) - in-player chords, script-opts, and binary auto-detection
|
|
||||||
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
|
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
|
||||||
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
|
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
|
||||||
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
|
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
|
||||||
|
|||||||
@@ -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.
|
|
||||||
+115
-97
@@ -33,7 +33,7 @@ If you want sentence, audio, and screenshot fields on your Anki cards, add this
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Field names must match a field on your Anki note type. Matching is case-insensitive (an exact match wins, then a lowercase comparison), but the spelling must otherwise match. See [Anki Integration](/anki-integration) for the full reference.
|
Field names must match your Anki note type exactly (case-sensitive). See [Anki Integration](/anki-integration) for the full reference.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -56,90 +56,123 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
|
|||||||
|
|
||||||
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||||
|
|
||||||
|
## Live Config Reload
|
||||||
|
|
||||||
|
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
||||||
|
|
||||||
|
Live-updated settings include:
|
||||||
|
|
||||||
|
- `subtitleStyle`
|
||||||
|
- `keybindings`
|
||||||
|
- `shortcuts`
|
||||||
|
- `secondarySub.defaultMode`
|
||||||
|
- `subtitleSidebar`
|
||||||
|
- `notifications`
|
||||||
|
- `logging`
|
||||||
|
- `jimaku`, `subsync`
|
||||||
|
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
|
||||||
|
- `stats.toggleKey`, `stats.markWatchedKey`
|
||||||
|
- `youtube.primarySubLanguages`
|
||||||
|
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
|
||||||
|
|
||||||
|
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
||||||
|
For restart-required sections, SubMiner shows a restart-needed notification.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
These are the commands you will actually use day to day. The full inventory of subcommands and flags lives in [Launcher Script](/launcher-script#subcommands).
|
On Windows, replace `SubMiner.AppImage` with `SubMiner.exe` in the direct packaged-app examples below.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer video.mkv # Play a specific file
|
# Browse and play videos
|
||||||
subminer # Browse the current directory (fzf picker)
|
subminer # Current directory (uses fzf)
|
||||||
subminer -R # Browse with the rofi picker instead
|
subminer -R # Use rofi instead of fzf
|
||||||
subminer -d ~/Anime -r # Browse a specific directory, recursively
|
subminer -d ~/Videos # Specific directory
|
||||||
subminer -H # Browse watch history, then replay/next/previous
|
subminer -r -d ~/Anime # Recursive search
|
||||||
|
subminer video.mkv # Play specific file (overlay auto-starts)
|
||||||
|
subminer --start video.mkv # Explicit overlay start (use when mpv.autoStartSubMiner is false in config)
|
||||||
|
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
|
||||||
subminer https://youtu.be/... # Play a YouTube URL
|
subminer https://youtu.be/... # Play a YouTube URL
|
||||||
subminer stats # Open the immersion stats dashboard
|
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||||
subminer doctor # Check dependencies, config, and the mpv socket
|
subminer -H # Browse watch history (replay/continue episodes, fzf or rofi picker)
|
||||||
subminer settings # Open the SubMiner settings window
|
subminer app --setup # Open first-run setup popup
|
||||||
subminer app --setup # Re-open first-run setup
|
subminer --version # Print the launcher's version
|
||||||
subminer -u # Check for updates
|
subminer -v # Same as above
|
||||||
```
|
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||||
|
subminer --log-level warn video.mkv # Set logging level explicitly
|
||||||
|
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
|
||||||
|
|
||||||
On **Windows** there is no `subminer` launcher. Use the **SubMiner mpv** shortcut for playback (see [Windows mpv Shortcut](#windows-mpv-shortcut)), and run `SubMiner.exe` directly for everything else.
|
# Options
|
||||||
|
subminer -T video.mkv # Disable texthooker server
|
||||||
Two flags are worth knowing early:
|
subminer -b x11 video.mkv # Force X11 backend
|
||||||
|
subminer video.mkv # No mpv profile passed by default
|
||||||
- `-a/--args` passes extra arguments straight to mpv, for example `subminer --args "--ao=alsa --volume=80" video.mkv`.
|
|
||||||
- `--log-level debug` turns on verbose logging when something is not working.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Less common launcher commands</b></summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer --start video.mkv # Explicit overlay start (when mpv.autoStartSubMiner is false)
|
|
||||||
subminer -S video.mkv # Also force the visible overlay on start
|
|
||||||
subminer -T video.mkv # Disable the texthooker server
|
|
||||||
subminer -b x11 video.mkv # Force a window backend
|
|
||||||
subminer -p gpu-hq video.mkv # Use a specific mpv profile
|
subminer -p gpu-hq video.mkv # Use a specific mpv profile
|
||||||
subminer ytsearch:"jp news" # Play the first YouTube search result
|
subminer jellyfin # Open Jellyfin setup window (subcommand form)
|
||||||
subminer texthooker # Texthooker-only mode (-o also opens the browser)
|
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
|
||||||
|
subminer jellyfin --logout # Clear stored Jellyfin token/session data
|
||||||
|
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
|
||||||
|
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
|
||||||
|
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
|
||||||
|
subminer doctor # Dependency + config + socket diagnostics
|
||||||
|
subminer logs -e # Export a sanitized log ZIP and print its path
|
||||||
|
subminer config path # Print active config path
|
||||||
|
subminer config show # Print active config contents
|
||||||
|
subminer mpv socket # Print active mpv socket path
|
||||||
|
subminer mpv status # Exit 0 if socket is ready, else exit 1
|
||||||
|
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
|
||||||
|
subminer sync media-box # Sync stats/watch history with an SSH host
|
||||||
|
subminer sync media-box --push # Merge this machine's stats into the host only
|
||||||
|
subminer sync media-box --pull # Merge the host's stats into this machine only
|
||||||
|
subminer sync media-box --check # Verify SSH and remote SubMiner without syncing
|
||||||
|
subminer sync media-box --json # Emit machine-readable NDJSON progress
|
||||||
|
subminer sync --ui # Open the Sync Stats & History window
|
||||||
|
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
|
||||||
|
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
|
||||||
|
subminer sync --make-temp # Create an internal sync temp directory
|
||||||
|
subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory
|
||||||
|
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
|
||||||
|
subminer dictionary --candidates /path/to/file.mkv
|
||||||
|
subminer dictionary --select 21355 /path/to/file.mkv
|
||||||
|
subminer texthooker # Launch texthooker-only mode
|
||||||
|
subminer texthooker -o # Launch texthooker and open it in your browser
|
||||||
|
subminer stats # Start the local stats server (see Immersion Tracking)
|
||||||
subminer stats -b # Start/reuse the background stats daemon
|
subminer stats -b # Start/reuse the background stats daemon
|
||||||
subminer stats -s # Stop the background stats daemon
|
subminer stats -s # Stop the background stats daemon
|
||||||
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows
|
subminer app --anilist-setup # Pass args directly to SubMiner binary (example: AniList login flow)
|
||||||
subminer stats rebuild # Rebuild rollup data
|
|
||||||
subminer doctor --refresh-known-words # Refresh the known-word cache
|
|
||||||
subminer logs -e # Export a sanitized log ZIP and print its path
|
|
||||||
subminer config path # Print the active config path
|
|
||||||
subminer config show # Print the active config contents
|
|
||||||
subminer mpv socket # Print the active mpv socket path
|
|
||||||
subminer mpv status # Exit 0 if the socket is ready, else exit 1
|
|
||||||
subminer mpv idle # Launch a detached idle mpv with SubMiner defaults
|
|
||||||
subminer app --stop # Stop the background app
|
|
||||||
subminer --version # Print the launcher's version
|
|
||||||
```
|
|
||||||
|
|
||||||
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
|
# Direct packaged app control
|
||||||
|
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
||||||
</details>
|
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
||||||
|
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
||||||
<details>
|
SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser
|
||||||
<summary><b>Direct packaged-app flags (advanced)</b></summary>
|
SubMiner.AppImage --setup # Open first-run setup popup
|
||||||
|
|
||||||
These call the app binary directly rather than going through the launcher. On Windows, replace `SubMiner.AppImage` with `SubMiner.exe`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
|
||||||
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
|
||||||
SubMiner.AppImage --texthooker # Texthooker only (no overlay window)
|
|
||||||
SubMiner.AppImage --setup # Open first-run setup
|
|
||||||
SubMiner.AppImage --stop # Stop overlay
|
SubMiner.AppImage --stop # Stop overlay
|
||||||
SubMiner.AppImage --start --toggle # Start mpv IPC + toggle visibility
|
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
||||||
SubMiner.AppImage --show-visible-overlay # Force show the visible overlay
|
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
||||||
SubMiner.AppImage --hide-visible-overlay # Force hide the visible overlay
|
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
||||||
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle bar
|
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
|
||||||
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
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
|
||||||
|
SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface
|
||||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||||
SubMiner.AppImage --settings # Open the SubMiner settings window
|
SubMiner.AppImage --settings # Open SubMiner settings window
|
||||||
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
|
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
|
||||||
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
|
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
|
||||||
SubMiner.AppImage --start --dev # Enable app/dev mode
|
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
|
||||||
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
|
SubMiner.AppImage --jellyfin-libraries
|
||||||
|
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
|
||||||
|
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
|
||||||
|
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
|
||||||
|
SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help
|
||||||
|
SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode
|
||||||
|
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
|
||||||
|
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
|
||||||
|
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
|
||||||
SubMiner.AppImage --help # Show all options
|
SubMiner.AppImage --help # Show all options
|
||||||
```
|
```
|
||||||
|
|
||||||
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still 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.
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -181,11 +214,18 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
|||||||
|
|
||||||
### Launcher Subcommands
|
### Launcher Subcommands
|
||||||
|
|
||||||
The launcher groups related work under subcommands: `jellyfin` (aliased `jf`), `stats`, `sync`, `dictionary` (aliased `dict`), `texthooker`, `doctor`, `settings`, `config`, `mpv`, `logs`, and `app` (aliased `bin`) for passing arguments straight to the SubMiner binary.
|
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||||
|
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||||
Every subcommand has its own help page, for example `subminer jellyfin -h`. See [Launcher Script - Subcommands](/launcher-script#subcommands) for the full table, and [Sync Between Machines](/launcher-script#sync-between-machines) for the SSH stats/history sync.
|
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||||
|
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
||||||
A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
- `subminer config`: config file helpers (`path`, `show`).
|
||||||
|
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||||
|
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Use `--push` to merge only this machine's data into the host, or `--pull` to merge only the host's data into this machine; both remain insert-only and do not make either database an exact mirror. Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
|
||||||
|
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||||
|
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||||
|
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
||||||
|
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
|
||||||
|
- Subcommand help pages are available (for example `subminer jellyfin -h`).
|
||||||
|
|
||||||
### First-Run Setup
|
### First-Run Setup
|
||||||
|
|
||||||
@@ -282,28 +322,6 @@ Notes:
|
|||||||
|
|
||||||
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
||||||
|
|
||||||
## Live Config Reload
|
|
||||||
|
|
||||||
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
|
||||||
|
|
||||||
Live-updated settings include:
|
|
||||||
|
|
||||||
- `subtitleStyle`
|
|
||||||
- `keybindings`
|
|
||||||
- `shortcuts`
|
|
||||||
- `secondarySub.defaultMode`
|
|
||||||
- `subtitleSidebar`
|
|
||||||
- `notifications`
|
|
||||||
- `logging`
|
|
||||||
- `jimaku`, `subsync`
|
|
||||||
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
|
|
||||||
- `stats.toggleKey`, `stats.markWatchedKey`
|
|
||||||
- `youtube.primarySubLanguages`
|
|
||||||
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
|
|
||||||
|
|
||||||
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
|
||||||
For restart-required sections, SubMiner shows a restart-needed notification.
|
|
||||||
|
|
||||||
## Controller Support
|
## Controller Support
|
||||||
|
|
||||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||||
|
|||||||
@@ -377,4 +377,4 @@ ws.on('message', async (raw) => {
|
|||||||
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
|
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
|
||||||
- [MPV Plugin](/mpv-plugin)
|
- [MPV Plugin](/mpv-plugin)
|
||||||
- [Launcher Script](/launcher-script)
|
- [Launcher Script](/launcher-script)
|
||||||
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan-texthooker)
|
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan--texthooker)
|
||||||
|
|||||||
@@ -153,6 +153,6 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
|
|||||||
|
|
||||||
- [Usage --- YouTube Playback](/usage#youtube-playback)
|
- [Usage --- YouTube Playback](/usage#youtube-playback)
|
||||||
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
|
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
|
||||||
- [Configuration --- Secondary Subtitles](/configuration#secondary-subtitles)
|
- [Configuration --- Secondary Subtitle](/configuration#secondary-subtitle)
|
||||||
- [Keyboard Shortcuts](/shortcuts)
|
- [Keyboard Shortcuts](/shortcuts)
|
||||||
- [Jellyfin Integration](/jellyfin-integration)
|
- [Jellyfin Integration](/jellyfin-integration)
|
||||||
|
|||||||
@@ -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.
|
- 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 version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
- If you need to repair a published release body (for example, a prior version’s 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.
|
- 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.
|
- 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`.
|
- 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`.
|
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
|
||||||
|
|||||||
@@ -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/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/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/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
|
## Architecture Intent
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Domain Ownership
|
# Domain Ownership
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-07-15
|
Last verified: 2026-05-23
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: you need to find the owner module for a behavior or test surface
|
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
|
## Product / Integration Domains
|
||||||
|
|
||||||
- Config system: `src/config/`; Anki resolution is composed by
|
- Config system: `src/config/`
|
||||||
`src/config/resolve/anki-connect.ts` from focused resolvers in
|
|
||||||
`src/config/resolve/anki-connect/`
|
|
||||||
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||||
- MPV runtime and protocol: `src/core/services/mpv*.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/`
|
- 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/`
|
- 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-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
- Stats app: `stats/`
|
||||||
in `src/core/services/stats-server/`
|
|
||||||
- Stats SPA: `stats/`
|
|
||||||
- Public docs site: `docs-site/`
|
- Public docs site: `docs-site/`
|
||||||
|
|
||||||
## Shared Contract Entry Points
|
## 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`
|
- Runtime-option contracts: `src/types/runtime-options.ts`
|
||||||
- Settings UI contracts: `src/types/settings.ts`
|
- Settings UI contracts: `src/types/settings.ts`
|
||||||
- Session-binding contracts: `src/types/session-bindings.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`
|
- Compatibility-only barrel: `src/types.ts`
|
||||||
|
|
||||||
## Ownership Heuristics
|
## Ownership Heuristics
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
|
|||||||
|
|
||||||
## Contract
|
## 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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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;
|
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
||||||
`--single-process` restores the shared-process mode for debugging.
|
`--single-process` restores the shared-process mode for debugging.
|
||||||
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
||||||
unit, and `scripts/**`.
|
unit, `scripts/**`, and the compiled runtime-compat slice.
|
||||||
- `.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.
|
|
||||||
|
|
||||||
## Default Handoff Gate
|
## 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.
|
- `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`.
|
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
||||||
- Every reusable quality-gate run uploads that LCOV file as the
|
- CI and release quality-gate runs upload that LCOV file as the `coverage-test-src` artifact.
|
||||||
`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.
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
launchAppCommandDetached,
|
|
||||||
launchAppBackgroundDetached,
|
launchAppBackgroundDetached,
|
||||||
launchTexthookerOnly,
|
launchTexthookerOnly,
|
||||||
|
runAppCommandInteractive,
|
||||||
runAppCommandWithInherit,
|
runAppCommandWithInherit,
|
||||||
} from '../mpv.js';
|
} from '../mpv.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
type AppCommandDeps = {
|
type AppCommandDeps = {
|
||||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||||
launchSyncUiDetached: (
|
runAppCommandInteractive: (appPath: string, appArgs: string[]) => void;
|
||||||
appPath: string,
|
|
||||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
|
||||||
) => void;
|
|
||||||
launchAppBackgroundDetached: (
|
launchAppBackgroundDetached: (
|
||||||
appPath: string,
|
appPath: string,
|
||||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||||
@@ -20,8 +17,7 @@ type AppCommandDeps = {
|
|||||||
|
|
||||||
const defaultAppCommandDeps: AppCommandDeps = {
|
const defaultAppCommandDeps: AppCommandDeps = {
|
||||||
runAppCommandWithInherit,
|
runAppCommandWithInherit,
|
||||||
launchSyncUiDetached: (appPath, logLevel) =>
|
runAppCommandInteractive,
|
||||||
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
|
|
||||||
launchAppBackgroundDetached,
|
launchAppBackgroundDetached,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +34,7 @@ export function runAppPassthroughCommand(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (args.syncUi) {
|
if (args.syncUi) {
|
||||||
deps.launchSyncUiDetached(appPath, args.logLevel);
|
deps.runAppCommandInteractive(appPath, ['--sync-window']);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!args.appPassthrough) {
|
if (!args.appPassthrough) {
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ test('app command starts default macOS background app detached from launcher', (
|
|||||||
runAppCommandWithInherit: () => {
|
runAppCommandWithInherit: () => {
|
||||||
calls.push('attached');
|
calls.push('attached');
|
||||||
},
|
},
|
||||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
runAppCommandInteractive: () => calls.push('interactive'),
|
||||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||||
calls.push(`detached:${appPath}:${logLevel}`);
|
calls.push(`detached:${appPath}:${logLevel}`);
|
||||||
},
|
},
|
||||||
@@ -226,7 +226,7 @@ test('app command starts default Linux background app detached from launcher', (
|
|||||||
runAppCommandWithInherit: () => {
|
runAppCommandWithInherit: () => {
|
||||||
calls.push('attached');
|
calls.push('attached');
|
||||||
},
|
},
|
||||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
runAppCommandInteractive: () => calls.push('interactive'),
|
||||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||||
calls.push(`detached:${appPath}:${logLevel}`);
|
calls.push(`detached:${appPath}:${logLevel}`);
|
||||||
},
|
},
|
||||||
@@ -247,7 +247,7 @@ test('app command keeps explicit passthrough args attached', () => {
|
|||||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||||
forwarded.push(appArgs);
|
forwarded.push(appArgs);
|
||||||
},
|
},
|
||||||
launchSyncUiDetached: () => detached.push('sync-ui'),
|
runAppCommandInteractive: () => detached.push('interactive'),
|
||||||
launchAppBackgroundDetached: () => {
|
launchAppBackgroundDetached: () => {
|
||||||
detached.push('detached');
|
detached.push('detached');
|
||||||
},
|
},
|
||||||
@@ -258,19 +258,19 @@ test('app command keeps explicit passthrough args attached', () => {
|
|||||||
assert.deepEqual(detached, []);
|
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();
|
const context = createContext();
|
||||||
context.args.syncUi = true;
|
context.args.syncUi = true;
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
const handled = runAppPassthroughCommand(context, {
|
const handled = runAppPassthroughCommand(context, {
|
||||||
runAppCommandWithInherit: () => calls.push('piped'),
|
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'),
|
launchAppBackgroundDetached: () => calls.push('detached'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
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 () => {
|
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import {
|
|||||||
collectVideos,
|
collectVideos,
|
||||||
findRofiTheme,
|
findRofiTheme,
|
||||||
formatPickerLaunchError,
|
formatPickerLaunchError,
|
||||||
formatRofiPrompt,
|
|
||||||
showFzfMenu,
|
showFzfMenu,
|
||||||
showRofiMenu,
|
showRofiMenu,
|
||||||
} from '../picker.js';
|
} from '../picker.js';
|
||||||
import {
|
import {
|
||||||
findNextEpisode,
|
findNextEpisode,
|
||||||
findPreviousEpisode,
|
|
||||||
groupHistoryBySeries,
|
groupHistoryBySeries,
|
||||||
listSeasonDirs,
|
listSeasonDirs,
|
||||||
materializeCoverArt,
|
materializeCoverArt,
|
||||||
@@ -25,167 +23,6 @@ import {
|
|||||||
import type { Args } from '../types.js';
|
import type { Args } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
|
|
||||||
|
|
||||||
export interface HistoryPlaybackSelection {
|
|
||||||
entry: HistorySeriesEntry;
|
|
||||||
videoPath: string;
|
|
||||||
themePath?: string | null;
|
|
||||||
entryIcon?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistorySessionMenuAction {
|
|
||||||
kind: HistorySessionAction;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildHistorySessionActions(
|
|
||||||
justPlayedPath: string,
|
|
||||||
previousEpisodePath: string | null,
|
|
||||||
nextEpisodePath: string | null,
|
|
||||||
): HistorySessionMenuAction[] {
|
|
||||||
const actions: HistorySessionMenuAction[] = [];
|
|
||||||
if (previousEpisodePath) {
|
|
||||||
actions.push({
|
|
||||||
kind: 'previous',
|
|
||||||
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
actions.push({
|
|
||||||
kind: 'replay',
|
|
||||||
label: `Rewatch episode: ${path.basename(justPlayedPath)}`,
|
|
||||||
});
|
|
||||||
if (nextEpisodePath) {
|
|
||||||
actions.push({
|
|
||||||
kind: 'next',
|
|
||||||
label: `Play next episode: ${path.basename(nextEpisodePath)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
actions.push(
|
|
||||||
{ kind: 'browse', label: 'Select / browse episode' },
|
|
||||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
|
||||||
);
|
|
||||||
return actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildHistoryEntryActions(
|
|
||||||
lastWatchedPath: string | null,
|
|
||||||
previousEpisodePath: string | null,
|
|
||||||
nextEpisodePath: string | null,
|
|
||||||
): HistorySessionMenuAction[] {
|
|
||||||
const actions: HistorySessionMenuAction[] = [];
|
|
||||||
if (previousEpisodePath) {
|
|
||||||
actions.push({
|
|
||||||
kind: 'previous',
|
|
||||||
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (lastWatchedPath) {
|
|
||||||
actions.push({
|
|
||||||
kind: 'replay',
|
|
||||||
label: `Replay last watched: ${path.basename(lastWatchedPath)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (nextEpisodePath) {
|
|
||||||
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisodePath)}` });
|
|
||||||
}
|
|
||||||
actions.push(
|
|
||||||
{ kind: 'browse', label: 'Browse episodes' },
|
|
||||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
|
||||||
);
|
|
||||||
return actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistoryPlaybackLoopDeps {
|
|
||||||
play: (videoPath: string) => Promise<void>;
|
|
||||||
pickPostPlaybackAction: (input: {
|
|
||||||
entry: HistorySeriesEntry;
|
|
||||||
justPlayedPath: string;
|
|
||||||
previousEpisodePath: string | null;
|
|
||||||
nextEpisodePath: string | null;
|
|
||||||
}) => Promise<HistorySessionAction | null>;
|
|
||||||
findPreviousEpisode: (videoPath: string) => string | null;
|
|
||||||
findNextEpisode: (videoPath: string) => string | null;
|
|
||||||
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runHistoryPlaybackLoop(
|
|
||||||
initial: HistoryPlaybackSelection,
|
|
||||||
deps: HistoryPlaybackLoopDeps,
|
|
||||||
): Promise<void> {
|
|
||||||
let videoPath = initial.videoPath;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
await deps.play(videoPath);
|
|
||||||
const previousEpisodePath = deps.findPreviousEpisode(videoPath);
|
|
||||||
const nextEpisodePath = deps.findNextEpisode(videoPath);
|
|
||||||
const action = await deps.pickPostPlaybackAction({
|
|
||||||
entry: initial.entry,
|
|
||||||
justPlayedPath: videoPath,
|
|
||||||
previousEpisodePath,
|
|
||||||
nextEpisodePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'replay':
|
|
||||||
break;
|
|
||||||
case 'previous':
|
|
||||||
if (!previousEpisodePath) return;
|
|
||||||
videoPath = previousEpisodePath;
|
|
||||||
break;
|
|
||||||
case 'next':
|
|
||||||
if (!nextEpisodePath) return;
|
|
||||||
videoPath = nextEpisodePath;
|
|
||||||
break;
|
|
||||||
case 'browse': {
|
|
||||||
const browsedPath = await deps.browseEpisodes(initial.entry);
|
|
||||||
if (!browsedPath) return;
|
|
||||||
videoPath = browsedPath;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'quit':
|
|
||||||
case null:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runHistorySession(
|
|
||||||
context: LauncherCommandContext,
|
|
||||||
play: (videoPath: string) => Promise<void>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const initial = await runHistoryCommand(context);
|
|
||||||
if (!initial) return false;
|
|
||||||
|
|
||||||
await runHistoryPlaybackLoop(initial, {
|
|
||||||
play,
|
|
||||||
findPreviousEpisode,
|
|
||||||
findNextEpisode,
|
|
||||||
browseEpisodes: async (entry) => browseEpisodes(entry, context, initial.themePath ?? null),
|
|
||||||
pickPostPlaybackAction: async ({
|
|
||||||
entry,
|
|
||||||
justPlayedPath,
|
|
||||||
previousEpisodePath,
|
|
||||||
nextEpisodePath,
|
|
||||||
}) => {
|
|
||||||
const actions = buildHistorySessionActions(
|
|
||||||
justPlayedPath,
|
|
||||||
previousEpisodePath,
|
|
||||||
nextEpisodePath,
|
|
||||||
);
|
|
||||||
const actionIdx = pickIndex(
|
|
||||||
actions.map((action) => action.label),
|
|
||||||
entry.displayName,
|
|
||||||
context.args.useRofi,
|
|
||||||
initial.themePath ?? null,
|
|
||||||
actions.map(() => initial.entryIcon ?? null),
|
|
||||||
);
|
|
||||||
return actionIdx < 0 ? null : actions[actionIdx]!.kind;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkPickerDependencies(args: Args): void {
|
function checkPickerDependencies(args: Args): void {
|
||||||
if (args.useRofi) {
|
if (args.useRofi) {
|
||||||
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
||||||
@@ -200,16 +37,7 @@ function showRofiIndexMenu(
|
|||||||
themePath: string | null,
|
themePath: string | null,
|
||||||
icons: Array<string | null> = [],
|
icons: Array<string | null> = [],
|
||||||
): number {
|
): number {
|
||||||
const rofiArgs = [
|
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
|
||||||
'-dmenu',
|
|
||||||
'-i',
|
|
||||||
'-matching',
|
|
||||||
'fuzzy',
|
|
||||||
'-format',
|
|
||||||
'i',
|
|
||||||
'-p',
|
|
||||||
formatRofiPrompt(prompt),
|
|
||||||
];
|
|
||||||
const hasIcons = icons.some(Boolean);
|
const hasIcons = icons.some(Boolean);
|
||||||
if (hasIcons) rofiArgs.push('-show-icons');
|
if (hasIcons) rofiArgs.push('-show-icons');
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
@@ -314,7 +142,7 @@ function browseEpisodes(
|
|||||||
if (seasons.length > 1) {
|
if (seasons.length > 1) {
|
||||||
const idx = pickIndex(
|
const idx = pickIndex(
|
||||||
seasons.map((season) => season.name),
|
seasons.map((season) => season.name),
|
||||||
`${entry.displayName}: Season`,
|
`${entry.displayName} — Season`,
|
||||||
args.useRofi,
|
args.useRofi,
|
||||||
themePath,
|
themePath,
|
||||||
);
|
);
|
||||||
@@ -327,9 +155,7 @@ function browseEpisodes(
|
|||||||
return pickEpisodeFromDir(dir, context);
|
return pickEpisodeFromDir(dir, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runHistoryCommand(
|
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
|
||||||
context: LauncherCommandContext,
|
|
||||||
): Promise<HistoryPlaybackSelection | null> {
|
|
||||||
const { args, scriptPath } = context;
|
const { args, scriptPath } = context;
|
||||||
|
|
||||||
checkPickerDependencies(args);
|
checkPickerDependencies(args);
|
||||||
@@ -370,14 +196,16 @@ export async function runHistoryCommand(
|
|||||||
|
|
||||||
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
||||||
const lastExists = fs.existsSync(lastPath);
|
const lastExists = fs.existsSync(lastPath);
|
||||||
const previousEpisode = findPreviousEpisode(lastPath);
|
|
||||||
const nextEpisode = findNextEpisode(lastPath);
|
const nextEpisode = findNextEpisode(lastPath);
|
||||||
|
|
||||||
const actions = buildHistoryEntryActions(
|
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
|
||||||
lastExists ? lastPath : null,
|
if (lastExists) {
|
||||||
previousEpisode,
|
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
|
||||||
nextEpisode,
|
}
|
||||||
);
|
if (nextEpisode) {
|
||||||
|
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
|
||||||
|
}
|
||||||
|
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
||||||
|
|
||||||
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||||
const actionIdx = pickIndex(
|
const actionIdx = pickIndex(
|
||||||
@@ -391,16 +219,10 @@ export async function runHistoryCommand(
|
|||||||
|
|
||||||
switch (actions[actionIdx]!.kind) {
|
switch (actions[actionIdx]!.kind) {
|
||||||
case 'replay':
|
case 'replay':
|
||||||
return { entry, videoPath: lastPath, themePath, entryIcon };
|
return lastPath;
|
||||||
case 'previous':
|
|
||||||
return previousEpisode ? { entry, videoPath: previousEpisode, themePath, entryIcon } : null;
|
|
||||||
case 'next':
|
case 'next':
|
||||||
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
|
return nextEpisode;
|
||||||
case 'browse': {
|
case 'browse':
|
||||||
const videoPath = browseEpisodes(entry, context, themePath);
|
return browseEpisodes(entry, context, themePath);
|
||||||
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
|
|
||||||
}
|
|
||||||
case 'quit':
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import path from 'node:path';
|
|
||||||
import {
|
|
||||||
buildHistoryEntryActions,
|
|
||||||
buildHistorySessionActions,
|
|
||||||
runHistoryPlaybackLoop,
|
|
||||||
} from './history-command.js';
|
|
||||||
import type { HistorySeriesEntry } from '../history.js';
|
|
||||||
|
|
||||||
type HistoryLoop = (
|
|
||||||
initial: { entry: HistorySeriesEntry; videoPath: string },
|
|
||||||
deps: {
|
|
||||||
play: (videoPath: string) => Promise<void>;
|
|
||||||
pickPostPlaybackAction: (input: {
|
|
||||||
entry: HistorySeriesEntry;
|
|
||||||
justPlayedPath: string;
|
|
||||||
previousEpisodePath: string | null;
|
|
||||||
nextEpisodePath: string | null;
|
|
||||||
}) => Promise<'previous' | 'replay' | 'next' | 'browse' | 'quit' | null>;
|
|
||||||
findPreviousEpisode: (videoPath: string) => string | null;
|
|
||||||
findNextEpisode: (videoPath: string) => string | null;
|
|
||||||
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
|
|
||||||
},
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
const typedRunHistoryPlaybackLoop: HistoryLoop = runHistoryPlaybackLoop;
|
|
||||||
|
|
||||||
function makeEntry(lastWatchedPath: string): HistorySeriesEntry {
|
|
||||||
return {
|
|
||||||
seriesRoot: path.dirname(lastWatchedPath),
|
|
||||||
displayName: 'Test Show',
|
|
||||||
coverBlobHash: null,
|
|
||||||
lastWatched: {
|
|
||||||
videoId: 1,
|
|
||||||
sourcePath: lastWatchedPath,
|
|
||||||
parsedTitle: 'Test Show',
|
|
||||||
parsedSeason: 1,
|
|
||||||
parsedEpisode: 1,
|
|
||||||
animeTitle: 'Test Show',
|
|
||||||
lastWatchedMs: 1,
|
|
||||||
coverBlobHash: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('history loop plays an initial selection, browsed selection, then quits', async () => {
|
|
||||||
assert.equal(
|
|
||||||
typeof runHistoryPlaybackLoop,
|
|
||||||
'function',
|
|
||||||
'history playback loop is not implemented',
|
|
||||||
);
|
|
||||||
const entry = makeEntry('/shows/test-show/episode-01.mkv');
|
|
||||||
const played: string[] = [];
|
|
||||||
const menuEntries: HistorySeriesEntry[] = [];
|
|
||||||
let menuCount = 0;
|
|
||||||
|
|
||||||
await typedRunHistoryPlaybackLoop(
|
|
||||||
{ entry, videoPath: '/shows/test-show/episode-02.mkv' },
|
|
||||||
{
|
|
||||||
play: async (videoPath) => {
|
|
||||||
played.push(videoPath);
|
|
||||||
},
|
|
||||||
pickPostPlaybackAction: async ({ entry: menuEntry }) => {
|
|
||||||
menuEntries.push(menuEntry);
|
|
||||||
return menuCount++ === 0 ? 'browse' : 'quit';
|
|
||||||
},
|
|
||||||
findPreviousEpisode: () => null,
|
|
||||||
findNextEpisode: () => null,
|
|
||||||
browseEpisodes: async (browseEntry) => {
|
|
||||||
assert.equal(browseEntry, entry);
|
|
||||||
return '/shows/test-show/episode-04.mkv';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(played, ['/shows/test-show/episode-02.mkv', '/shows/test-show/episode-04.mkv']);
|
|
||||||
assert.deepEqual(menuEntries, [entry, entry]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history replay uses the actual just-played path instead of the database row', async () => {
|
|
||||||
assert.equal(
|
|
||||||
typeof runHistoryPlaybackLoop,
|
|
||||||
'function',
|
|
||||||
'history playback loop is not implemented',
|
|
||||||
);
|
|
||||||
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
|
|
||||||
const played: string[] = [];
|
|
||||||
const menuPaths: string[] = [];
|
|
||||||
let menuCount = 0;
|
|
||||||
|
|
||||||
await typedRunHistoryPlaybackLoop(
|
|
||||||
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
|
|
||||||
{
|
|
||||||
play: async (videoPath) => {
|
|
||||||
played.push(videoPath);
|
|
||||||
},
|
|
||||||
pickPostPlaybackAction: async ({ justPlayedPath }) => {
|
|
||||||
menuPaths.push(justPlayedPath);
|
|
||||||
return menuCount++ === 0 ? 'replay' : 'quit';
|
|
||||||
},
|
|
||||||
findPreviousEpisode: () => null,
|
|
||||||
findNextEpisode: () => '/shows/test-show/episode-08.mkv',
|
|
||||||
browseEpisodes: async () => null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-07.mkv']);
|
|
||||||
assert.deepEqual(menuPaths, [
|
|
||||||
'/shows/test-show/episode-07.mkv',
|
|
||||||
'/shows/test-show/episode-07.mkv',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history next is computed from the actual just-played path', async () => {
|
|
||||||
assert.equal(
|
|
||||||
typeof runHistoryPlaybackLoop,
|
|
||||||
'function',
|
|
||||||
'history playback loop is not implemented',
|
|
||||||
);
|
|
||||||
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
|
|
||||||
const played: string[] = [];
|
|
||||||
const nextInputs: string[] = [];
|
|
||||||
let menuCount = 0;
|
|
||||||
|
|
||||||
await typedRunHistoryPlaybackLoop(
|
|
||||||
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
|
|
||||||
{
|
|
||||||
play: async (videoPath) => {
|
|
||||||
played.push(videoPath);
|
|
||||||
},
|
|
||||||
pickPostPlaybackAction: async ({ nextEpisodePath }) => {
|
|
||||||
if (menuCount++ === 0) {
|
|
||||||
assert.equal(nextEpisodePath, '/shows/test-show/episode-08.mkv');
|
|
||||||
return 'next';
|
|
||||||
}
|
|
||||||
assert.equal(nextEpisodePath, null);
|
|
||||||
return 'quit';
|
|
||||||
},
|
|
||||||
findPreviousEpisode: () => null,
|
|
||||||
findNextEpisode: (videoPath) => {
|
|
||||||
nextInputs.push(videoPath);
|
|
||||||
return videoPath.endsWith('episode-07.mkv') ? '/shows/test-show/episode-08.mkv' : null;
|
|
||||||
},
|
|
||||||
browseEpisodes: async () => null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-08.mkv']);
|
|
||||||
assert.deepEqual(nextInputs, [
|
|
||||||
'/shows/test-show/episode-07.mkv',
|
|
||||||
'/shows/test-show/episode-08.mkv',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history show menu offers previous, rewatch, next, browse, and quit in order', () => {
|
|
||||||
assert.equal(
|
|
||||||
typeof buildHistorySessionActions,
|
|
||||||
'function',
|
|
||||||
'history session actions are not implemented',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
buildHistorySessionActions(
|
|
||||||
'/shows/test-show/episode-07.mkv',
|
|
||||||
'/shows/test-show/episode-06.mkv',
|
|
||||||
'/shows/test-show/episode-08.mkv',
|
|
||||||
),
|
|
||||||
[
|
|
||||||
{ kind: 'previous', label: 'Previous episode: episode-06.mkv' },
|
|
||||||
{ kind: 'replay', label: 'Rewatch episode: episode-07.mkv' },
|
|
||||||
{ kind: 'next', label: 'Play next episode: episode-08.mkv' },
|
|
||||||
{ kind: 'browse', label: 'Select / browse episode' },
|
|
||||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history show menu omits previous and next when the just-played episode has neither', () => {
|
|
||||||
assert.equal(
|
|
||||||
typeof buildHistorySessionActions,
|
|
||||||
'function',
|
|
||||||
'history session actions are not implemented',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(buildHistorySessionActions('/shows/test-show/finale.mkv', null, null), [
|
|
||||||
{ kind: 'replay', label: 'Rewatch episode: finale.mkv' },
|
|
||||||
{ kind: 'browse', label: 'Select / browse episode' },
|
|
||||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history entry menu offers previous, replay, and next before playback starts', () => {
|
|
||||||
assert.equal(
|
|
||||||
typeof buildHistoryEntryActions,
|
|
||||||
'function',
|
|
||||||
'history entry actions not implemented',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
buildHistoryEntryActions(
|
|
||||||
'/shows/test-show/episode-03.mkv',
|
|
||||||
'/shows/test-show/episode-02.mkv',
|
|
||||||
'/shows/test-show/episode-04.mkv',
|
|
||||||
),
|
|
||||||
[
|
|
||||||
{ kind: 'previous', label: 'Previous episode: episode-02.mkv' },
|
|
||||||
{ kind: 'replay', label: 'Replay last watched: episode-03.mkv' },
|
|
||||||
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
|
|
||||||
{ kind: 'browse', label: 'Browse episodes' },
|
|
||||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history entry menu omits replay when the last watched file is gone', () => {
|
|
||||||
assert.deepEqual(buildHistoryEntryActions(null, null, '/shows/test-show/episode-04.mkv'), [
|
|
||||||
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
|
|
||||||
{ kind: 'browse', label: 'Browse episodes' },
|
|
||||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
|
|
||||||
assert.equal(
|
|
||||||
typeof runHistoryPlaybackLoop,
|
|
||||||
'function',
|
|
||||||
'history playback loop is not implemented',
|
|
||||||
);
|
|
||||||
const entry = makeEntry('/shows/test-show/stale-episode-09.mkv');
|
|
||||||
const played: string[] = [];
|
|
||||||
const previousInputs: string[] = [];
|
|
||||||
const previousSeenByMenu: Array<string | null> = [];
|
|
||||||
let menuCount = 0;
|
|
||||||
|
|
||||||
await typedRunHistoryPlaybackLoop(
|
|
||||||
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
|
|
||||||
{
|
|
||||||
play: async (videoPath) => {
|
|
||||||
played.push(videoPath);
|
|
||||||
},
|
|
||||||
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
|
|
||||||
previousSeenByMenu.push(previousEpisodePath);
|
|
||||||
return menuCount++ === 0 ? 'previous' : 'quit';
|
|
||||||
},
|
|
||||||
findPreviousEpisode: (videoPath) => {
|
|
||||||
previousInputs.push(videoPath);
|
|
||||||
if (videoPath.endsWith('episode-07.mkv')) return '/shows/test-show/episode-06.mkv';
|
|
||||||
if (videoPath.endsWith('episode-06.mkv')) return '/shows/test-show/episode-05.mkv';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
findNextEpisode: () => null,
|
|
||||||
browseEpisodes: async () => null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-06.mkv']);
|
|
||||||
assert.deepEqual(previousInputs, [
|
|
||||||
'/shows/test-show/episode-07.mkv',
|
|
||||||
'/shows/test-show/episode-06.mkv',
|
|
||||||
]);
|
|
||||||
assert.deepEqual(previousSeenByMenu, [
|
|
||||||
'/shows/test-show/episode-06.mkv',
|
|
||||||
'/shows/test-show/episode-05.mkv',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history playback loop stops advancing when previous is chosen with no prior episode', async () => {
|
|
||||||
assert.equal(
|
|
||||||
typeof runHistoryPlaybackLoop,
|
|
||||||
'function',
|
|
||||||
'history playback loop is not implemented',
|
|
||||||
);
|
|
||||||
const entry = makeEntry('/shows/test-show/episode-01.mkv');
|
|
||||||
const played: string[] = [];
|
|
||||||
|
|
||||||
await typedRunHistoryPlaybackLoop(
|
|
||||||
{ entry, videoPath: '/shows/test-show/episode-01.mkv' },
|
|
||||||
{
|
|
||||||
play: async (videoPath) => {
|
|
||||||
played.push(videoPath);
|
|
||||||
},
|
|
||||||
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
|
|
||||||
assert.equal(previousEpisodePath, null);
|
|
||||||
return 'previous';
|
|
||||||
},
|
|
||||||
findPreviousEpisode: () => null,
|
|
||||||
findNextEpisode: () => null,
|
|
||||||
browseEpisodes: async () => null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(played, ['/shows/test-show/episode-01.mkv']);
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,7 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
|
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||||
import { state } from '../mpv.js';
|
import { state } from '../mpv.js';
|
||||||
|
|
||||||
function createContext(): LauncherCommandContext {
|
function createContext(): LauncherCommandContext {
|
||||||
@@ -37,7 +37,17 @@ function createContext(): LauncherCommandContext {
|
|||||||
useRofi: false,
|
useRofi: false,
|
||||||
history: false,
|
history: false,
|
||||||
sync: false,
|
sync: false,
|
||||||
syncCliTokens: [],
|
syncHost: '',
|
||||||
|
syncSnapshotPath: '',
|
||||||
|
syncMergePath: '',
|
||||||
|
syncDirection: 'both',
|
||||||
|
syncRemoteCmd: '',
|
||||||
|
syncDbPath: '',
|
||||||
|
syncForce: false,
|
||||||
|
syncJson: false,
|
||||||
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncUi: false,
|
syncUi: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
logRotation: 7,
|
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 () => {
|
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
|||||||
|
|
||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const SETUP_POLL_INTERVAL_MS = 500;
|
const SETUP_POLL_INTERVAL_MS = 500;
|
||||||
const cleanupRegisteredProcessAdapters = new WeakSet<LauncherCommandContext['processAdapter']>();
|
|
||||||
|
|
||||||
function getLauncherConfigDir(): string {
|
function getLauncherConfigDir(): string {
|
||||||
return getDefaultConfigDir({
|
return getDefaultConfigDir({
|
||||||
@@ -93,10 +92,8 @@ async function chooseTarget(
|
|||||||
return { target: selected, kind: 'file' };
|
return { target: selected, kind: 'file' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerCleanup(context: LauncherCommandContext): void {
|
function registerCleanup(context: LauncherCommandContext): void {
|
||||||
const { args, processAdapter } = context;
|
const { args, processAdapter } = context;
|
||||||
if (cleanupRegisteredProcessAdapters.has(processAdapter)) return;
|
|
||||||
|
|
||||||
processAdapter.onSignal('SIGINT', () => {
|
processAdapter.onSignal('SIGINT', () => {
|
||||||
stopOverlay(args);
|
stopOverlay(args);
|
||||||
processAdapter.exit(130);
|
processAdapter.exit(130);
|
||||||
@@ -105,7 +102,6 @@ export function registerCleanup(context: LauncherCommandContext): void {
|
|||||||
stopOverlay(args);
|
stopOverlay(args);
|
||||||
processAdapter.exit(143);
|
processAdapter.exit(143);
|
||||||
});
|
});
|
||||||
cleanupRegisteredProcessAdapters.add(processAdapter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
|
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import type { Args } from '../types.js';
|
import type { Args } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.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(
|
function makeContext(
|
||||||
overrides: Partial<Args>,
|
overrides: Partial<Args>,
|
||||||
@@ -11,7 +11,17 @@ function makeContext(
|
|||||||
return {
|
return {
|
||||||
args: {
|
args: {
|
||||||
sync: true,
|
sync: true,
|
||||||
syncCliTokens: [],
|
syncHost: '',
|
||||||
|
syncSnapshotPath: '',
|
||||||
|
syncMergePath: '',
|
||||||
|
syncDirection: 'both',
|
||||||
|
syncRemoteCmd: '',
|
||||||
|
syncDbPath: '',
|
||||||
|
syncForce: false,
|
||||||
|
syncJson: false,
|
||||||
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
logLevel: 'warn',
|
logLevel: 'warn',
|
||||||
...overrides,
|
...overrides,
|
||||||
} as Args,
|
} as Args,
|
||||||
@@ -25,6 +35,24 @@ function makeContext(
|
|||||||
} as unknown as LauncherCommandContext;
|
} 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 () => {
|
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
|
||||||
const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
|
const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
|
||||||
const deps: Partial<SyncCommandDeps> = {
|
const deps: Partial<SyncCommandDeps> = {
|
||||||
@@ -34,7 +62,7 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps),
|
await runSyncCommand(makeContext({ syncHost: 'media-box', syncJson: true }), deps),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.deepEqual(spawned, [
|
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);
|
assert.equal(spawned.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
|
test('buildSyncCliArgv forwards every sync option', () => {
|
||||||
const spawned: string[][] = [];
|
assert.deepEqual(
|
||||||
const deps: Partial<SyncCommandDeps> = {
|
buildSyncCliArgv(
|
||||||
runAppCommand: (_appPath, appArgs) => {
|
makeArgs({
|
||||||
spawned.push(appArgs);
|
syncHost: 'media-box',
|
||||||
},
|
syncDirection: 'pull',
|
||||||
};
|
syncRemoteCmd: '/opt/SubMiner.AppImage',
|
||||||
|
syncDbPath: '/tmp/db.sqlite',
|
||||||
await runSyncCommand(
|
syncForce: true,
|
||||||
makeContext({
|
syncJson: true,
|
||||||
syncCliTokens: [
|
logLevel: 'debug',
|
||||||
'media-box',
|
}),
|
||||||
'--pull',
|
),
|
||||||
'--remote-cmd',
|
|
||||||
'/opt/SubMiner.AppImage',
|
|
||||||
'--db',
|
|
||||||
'/tmp/db.sqlite',
|
|
||||||
'--force',
|
|
||||||
'--json',
|
|
||||||
],
|
|
||||||
logLevel: 'debug',
|
|
||||||
}),
|
|
||||||
deps,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(spawned, [
|
|
||||||
[
|
[
|
||||||
'--sync-cli',
|
'--sync-cli',
|
||||||
'sync',
|
'sync',
|
||||||
@@ -88,7 +103,32 @@ test('runSyncCommand forwards tokens verbatim and appends the effective log leve
|
|||||||
'--log-level',
|
'--log-level',
|
||||||
'debug',
|
'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 () => {
|
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(
|
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\)/,
|
/SubMiner app binary not found \(sync runs inside the app\)/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { SYNC_CLI_FLAG } from '../../src/core/services/stats-sync/cli-args.js';
|
|
||||||
import { fail } from '../log.js';
|
import { fail } from '../log.js';
|
||||||
import { runAppCommandInteractive } from '../mpv.js';
|
import { runAppCommandInteractive } from '../mpv.js';
|
||||||
|
import type { Args } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
export interface SyncCommandDeps {
|
export interface SyncCommandDeps {
|
||||||
@@ -13,12 +13,46 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
|
|||||||
fail,
|
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 sync` is a thin proxy: the sync engine only executes inside the
|
||||||
* SubMiner app (--sync-cli mode, libsql). The launcher contributes its
|
* SubMiner app (--sync-cli mode, libsql), so the launcher and the app cannot
|
||||||
* parser/help and app discovery; the app's parseSyncCliTokens owns validation,
|
* drift apart. The launcher contributes its parser/help and app discovery;
|
||||||
* so its errors reach the terminal through the child's inherited stdio. The
|
* the child owns the terminal and its exit code becomes the launcher's.
|
||||||
* child owns the terminal and its exit code becomes the launcher's.
|
|
||||||
*/
|
*/
|
||||||
export async function runSyncCommand(
|
export async function runSyncCommand(
|
||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
@@ -34,12 +68,6 @@ export async function runSyncCommand(
|
|||||||
);
|
);
|
||||||
return true; // fail() never returns; this only satisfies control-flow analysis
|
return true; // fail() never returns; this only satisfies control-flow analysis
|
||||||
}
|
}
|
||||||
deps.runAppCommand(context.appPath, [
|
deps.runAppCommand(context.appPath, buildSyncCliArgv(context.args));
|
||||||
SYNC_CLI_FLAG,
|
|
||||||
'sync',
|
|
||||||
...context.args.syncCliTokens,
|
|
||||||
'--log-level',
|
|
||||||
context.args.logLevel,
|
|
||||||
]);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,17 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
statsLogLevel: null,
|
statsLogLevel: null,
|
||||||
syncTriggered: false,
|
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,
|
syncLogLevel: null,
|
||||||
syncUiTriggered: false,
|
syncUiTriggered: false,
|
||||||
syncUiLogLevel: null,
|
syncUiLogLevel: null,
|
||||||
@@ -187,7 +197,17 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
|||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
statsLogLevel: null,
|
statsLogLevel: null,
|
||||||
syncTriggered: false,
|
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,
|
syncLogLevel: null,
|
||||||
syncUiTriggered: false,
|
syncUiTriggered: false,
|
||||||
syncUiLogLevel: null,
|
syncUiLogLevel: null,
|
||||||
@@ -231,7 +251,17 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
|||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
statsLogLevel: null,
|
statsLogLevel: null,
|
||||||
syncTriggered: false,
|
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,
|
syncLogLevel: null,
|
||||||
syncUiTriggered: false,
|
syncUiTriggered: false,
|
||||||
syncUiLogLevel: null,
|
syncUiLogLevel: null,
|
||||||
@@ -273,7 +303,17 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
|||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
statsLogLevel: null,
|
statsLogLevel: null,
|
||||||
syncTriggered: false,
|
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,
|
syncLogLevel: null,
|
||||||
syncUiTriggered: false,
|
syncUiTriggered: false,
|
||||||
syncUiLogLevel: null,
|
syncUiLogLevel: null,
|
||||||
|
|||||||
@@ -200,7 +200,17 @@ export function createDefaultArgs(
|
|||||||
useRofi: false,
|
useRofi: false,
|
||||||
history: false,
|
history: false,
|
||||||
sync: false,
|
sync: false,
|
||||||
syncCliTokens: [],
|
syncHost: '',
|
||||||
|
syncSnapshotPath: '',
|
||||||
|
syncMergePath: '',
|
||||||
|
syncDirection: 'both',
|
||||||
|
syncRemoteCmd: '',
|
||||||
|
syncDbPath: '',
|
||||||
|
syncForce: false,
|
||||||
|
syncJson: false,
|
||||||
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncUi: false,
|
syncUi: false,
|
||||||
logLevel: loggingConfig.level ?? 'warn',
|
logLevel: loggingConfig.level ?? 'warn',
|
||||||
logRotation: loggingConfig.rotation ?? 7,
|
logRotation: loggingConfig.rotation ?? 7,
|
||||||
@@ -269,7 +279,17 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
}
|
}
|
||||||
if (invocations.syncTriggered) {
|
if (invocations.syncTriggered) {
|
||||||
parsed.sync = true;
|
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.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
|
||||||
}
|
}
|
||||||
if (invocations.syncUiTriggered) {
|
if (invocations.syncUiTriggered) {
|
||||||
|
|||||||
@@ -43,66 +43,42 @@ test('parseCliPrograms captures texthooker browser-open flag', () => {
|
|||||||
assert.equal(result.invocations.texthookerOpenBrowser, true);
|
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');
|
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');
|
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.equal(push.invocations.syncDirection, 'push');
|
||||||
assert.deepEqual(check.invocations.syncCliTokens, ['media-box', '--check', '--json']);
|
assert.equal(pull.invocations.syncDirection, 'pull');
|
||||||
|
|
||||||
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']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseCliPrograms leaves sync validation to the app parser', () => {
|
test('parseCliPrograms rejects conflicting or hostless one-way sync directions', () => {
|
||||||
// Invalid combinations are forwarded; the app's parseSyncCliTokens rejects them.
|
assert.throws(
|
||||||
const invalid = parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer');
|
() => parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer'),
|
||||||
assert.equal(invalid.invocations.syncTriggered, true);
|
/--push and --pull cannot be combined/,
|
||||||
assert.deepEqual(invalid.invocations.syncCliTokens, ['media-box', '--push', '--pull']);
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => parseCliPrograms(['sync', '--snapshot', '/tmp/stats.sqlite', '--push'], 'subminer'),
|
||||||
|
/--push and --pull require a host/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const empty = parseCliPrograms(['sync'], 'subminer');
|
test('parseCliPrograms captures sync --json and --check flags', () => {
|
||||||
assert.equal(empty.invocations.syncTriggered, true);
|
const json = parseCliPrograms(['sync', 'media-box', '--json'], 'subminer');
|
||||||
assert.deepEqual(empty.invocations.syncCliTokens, []);
|
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', () => {
|
test('parseCliPrograms captures sync --ui', () => {
|
||||||
@@ -115,16 +91,3 @@ test('parseCliPrograms captures sync --ui', () => {
|
|||||||
/--ui cannot be combined/,
|
/--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.',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -39,7 +39,17 @@ export interface CliInvocations {
|
|||||||
statsCleanupLifetime: boolean;
|
statsCleanupLifetime: boolean;
|
||||||
statsLogLevel: string | null;
|
statsLogLevel: string | null;
|
||||||
syncTriggered: boolean;
|
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;
|
syncLogLevel: string | null;
|
||||||
syncUiTriggered: boolean;
|
syncUiTriggered: boolean;
|
||||||
syncUiLogLevel: string | null;
|
syncUiLogLevel: string | null;
|
||||||
@@ -171,7 +181,17 @@ export function parseCliPrograms(
|
|||||||
let statsCleanupLifetime = false;
|
let statsCleanupLifetime = false;
|
||||||
let statsLogLevel: string | null = null;
|
let statsLogLevel: string | null = null;
|
||||||
let syncTriggered = false;
|
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 syncLogLevel: string | null = null;
|
||||||
let syncUiTriggered = false;
|
let syncUiTriggered = false;
|
||||||
let syncUiLogLevel: string | null = null;
|
let syncUiLogLevel: string | null = null;
|
||||||
@@ -340,8 +360,6 @@ export function parseCliPrograms(
|
|||||||
check ||
|
check ||
|
||||||
makeTemp ||
|
makeTemp ||
|
||||||
removeTemp ||
|
removeTemp ||
|
||||||
options.remoteCmd !== undefined ||
|
|
||||||
options.db !== undefined ||
|
|
||||||
options.json === true ||
|
options.json === true ||
|
||||||
options.force === true
|
options.force === true
|
||||||
) {
|
) {
|
||||||
@@ -351,25 +369,49 @@ export function parseCliPrograms(
|
|||||||
syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No validation here: the app's parseSyncCliTokens owns the sync rules
|
if (push && pull) {
|
||||||
// and its error text reaches the terminal through the child's stdio.
|
throw new Error('Sync --push and --pull cannot be combined.');
|
||||||
const remoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() : '';
|
}
|
||||||
const dbPath = typeof options.db === 'string' ? options.db.trim() : '';
|
if ((push || pull) && !host) {
|
||||||
const tokens: string[] = [];
|
throw new Error('Sync --push and --pull require a host.');
|
||||||
if (host) tokens.push(host);
|
}
|
||||||
if (snapshot) tokens.push('--snapshot', snapshot);
|
if (check && !host) {
|
||||||
if (merge) tokens.push('--merge', merge);
|
throw new Error('Sync --check requires a host.');
|
||||||
if (makeTemp) tokens.push('--make-temp');
|
}
|
||||||
if (removeTemp) tokens.push('--remove-temp', removeTemp);
|
if (check && (push || pull || snapshot || merge)) {
|
||||||
if (push) tokens.push('--push');
|
throw new Error(
|
||||||
if (pull) tokens.push('--pull');
|
'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
|
||||||
if (check) tokens.push('--check');
|
);
|
||||||
if (remoteCmd) tokens.push('--remote-cmd', remoteCmd);
|
}
|
||||||
if (dbPath) tokens.push('--db', dbPath);
|
if ((makeTemp || removeTemp) && (push || pull || check)) {
|
||||||
if (options.force === true) tokens.push('--force');
|
throw new Error('Sync --make-temp/--remove-temp cannot be combined with other sync options.');
|
||||||
if (options.json === true) tokens.push('--json');
|
}
|
||||||
|
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;
|
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;
|
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -485,7 +527,17 @@ export function parseCliPrograms(
|
|||||||
statsCleanupLifetime,
|
statsCleanupLifetime,
|
||||||
statsLogLevel,
|
statsLogLevel,
|
||||||
syncTriggered,
|
syncTriggered,
|
||||||
syncCliTokens,
|
syncHost,
|
||||||
|
syncSnapshotPath,
|
||||||
|
syncMergePath,
|
||||||
|
syncDirection,
|
||||||
|
syncRemoteCmd,
|
||||||
|
syncDbPath,
|
||||||
|
syncForce,
|
||||||
|
syncJson,
|
||||||
|
syncCheck,
|
||||||
|
syncMakeTemp,
|
||||||
|
syncRemoveTempPath,
|
||||||
syncLogLevel,
|
syncLogLevel,
|
||||||
syncUiTriggered,
|
syncUiTriggered,
|
||||||
syncUiLogLevel,
|
syncUiLogLevel,
|
||||||
|
|||||||
+28
-2
@@ -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 { 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 type { HistoryVideoRow } from './history-types.js';
|
||||||
import { resolveImmersionDbPath } from '../src/core/services/stats-sync/db-path.js';
|
import { resolvePathMaybe } from './util.js';
|
||||||
import {
|
import {
|
||||||
isReadonlyWalRetryError,
|
isReadonlyWalRetryError,
|
||||||
withReadonlyWalRetry,
|
withReadonlyWalRetry,
|
||||||
} from '../src/core/services/stats-sync/wal-retry.js';
|
} 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 {
|
interface RawHistoryRow {
|
||||||
video_id: number;
|
video_id: number;
|
||||||
|
|||||||
@@ -130,46 +130,3 @@ export function findNextEpisode(lastPath: string): string | null {
|
|||||||
|
|
||||||
return findFirstEpisodeInNextSeason(resolvedLast, dir);
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Database } from 'bun:sqlite';
|
|||||||
import {
|
import {
|
||||||
detectImageExtension,
|
detectImageExtension,
|
||||||
findNextEpisode,
|
findNextEpisode,
|
||||||
findPreviousEpisode,
|
|
||||||
groupHistoryBySeries,
|
groupHistoryBySeries,
|
||||||
isReadonlyWalRetryError,
|
isReadonlyWalRetryError,
|
||||||
listSeasonDirs,
|
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');
|
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||||
|
|
||||||
function createHistoryDb(
|
function createHistoryDb(
|
||||||
|
|||||||
@@ -31,7 +31,17 @@ function createArgs(): Args {
|
|||||||
useRofi: false,
|
useRofi: false,
|
||||||
history: false,
|
history: false,
|
||||||
sync: false,
|
sync: false,
|
||||||
syncCliTokens: [],
|
syncHost: '',
|
||||||
|
syncSnapshotPath: '',
|
||||||
|
syncMergePath: '',
|
||||||
|
syncDirection: 'both',
|
||||||
|
syncRemoteCmd: '',
|
||||||
|
syncDbPath: '',
|
||||||
|
syncForce: false,
|
||||||
|
syncJson: false,
|
||||||
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncUi: false,
|
syncUi: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
logRotation: 7,
|
logRotation: 7,
|
||||||
|
|||||||
+6
-8
@@ -21,7 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
|
|||||||
import { runLogsCommand } from './commands/logs-command.js';
|
import { runLogsCommand } from './commands/logs-command.js';
|
||||||
import { runStatsCommand } from './commands/stats-command.js';
|
import { runStatsCommand } from './commands/stats-command.js';
|
||||||
import { runJellyfinCommand } from './commands/jellyfin-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 { runSyncCommand } from './commands/sync-command.js';
|
||||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||||
import { runUpdateCommand } from './commands/update-command.js';
|
import { runUpdateCommand } from './commands/update-command.js';
|
||||||
@@ -149,15 +149,13 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (appContext.args.history) {
|
if (appContext.args.history) {
|
||||||
const played = await runHistorySession(appContext, async (videoPath) => {
|
const selected = await runHistoryCommand(appContext);
|
||||||
appContext.args.target = videoPath;
|
if (!selected) {
|
||||||
appContext.args.targetKind = 'file';
|
|
||||||
await runPlaybackCommand(appContext);
|
|
||||||
});
|
|
||||||
if (!played) {
|
|
||||||
log('info', args.logLevel, 'No watch history selection made, exiting');
|
log('info', args.logLevel, 'No watch history selection made, exiting');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
appContext.args.target = selected;
|
||||||
|
appContext.args.targetKind = 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
await runPlaybackCommand(appContext);
|
await runPlaybackCommand(appContext);
|
||||||
|
|||||||
+11
-32
@@ -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', () => {
|
test('parseMpvArgString preserves empty quoted tokens', () => {
|
||||||
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
|
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
|
||||||
'--title',
|
'--title',
|
||||||
@@ -603,7 +572,17 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
useRofi: false,
|
useRofi: false,
|
||||||
history: false,
|
history: false,
|
||||||
sync: false,
|
sync: false,
|
||||||
syncCliTokens: [],
|
syncHost: '',
|
||||||
|
syncSnapshotPath: '',
|
||||||
|
syncMergePath: '',
|
||||||
|
syncDirection: 'both',
|
||||||
|
syncRemoteCmd: '',
|
||||||
|
syncDbPath: '',
|
||||||
|
syncForce: false,
|
||||||
|
syncJson: false,
|
||||||
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncUi: false,
|
syncUi: false,
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
logRotation: 7,
|
logRotation: 7,
|
||||||
|
|||||||
@@ -1277,15 +1277,6 @@ function shouldTransportAppArgsForAppImage(appPath: string): boolean {
|
|||||||
return process.platform === 'linux' && /\.AppImage$/i.test(appPath);
|
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(
|
function buildAppEnv(
|
||||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||||
extraEnv: NodeJS.ProcessEnv = {},
|
extraEnv: NodeJS.ProcessEnv = {},
|
||||||
@@ -1441,16 +1432,6 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
|
|||||||
|
|
||||||
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
|
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
|
||||||
if (shouldTransportAppArgsForAppImage(appPath)) {
|
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 {
|
return {
|
||||||
command: appPath,
|
command: appPath,
|
||||||
args: [],
|
args: [],
|
||||||
|
|||||||
+1
-16
@@ -3,22 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { findRofiTheme, formatRofiPrompt } from './picker';
|
import { findRofiTheme } from './picker';
|
||||||
|
|
||||||
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
|
||||||
|
|
||||||
test('formatRofiPrompt appends a single trailing space', () => {
|
|
||||||
assert.equal(formatRofiPrompt('Select Video'), 'Select Video ');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatRofiPrompt collapses existing trailing whitespace to one space', () => {
|
|
||||||
assert.equal(formatRofiPrompt('Watch History '), 'Watch History ');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatRofiPrompt leaves an empty prompt empty', () => {
|
|
||||||
assert.equal(formatRofiPrompt(''), '');
|
|
||||||
assert.equal(formatRofiPrompt(' '), '');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+4
-13
@@ -17,22 +17,13 @@ export function escapeShellSingle(value: string): string {
|
|||||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rofi renders the prompt flush against the input field, so keep exactly one
|
|
||||||
* trailing space to separate them.
|
|
||||||
*/
|
|
||||||
export function formatRofiPrompt(prompt: string): string {
|
|
||||||
const trimmed = prompt.trimEnd();
|
|
||||||
return trimmed ? `${trimmed} ` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showRofiFlatMenu(
|
export function showRofiFlatMenu(
|
||||||
items: string[],
|
items: string[],
|
||||||
prompt: string,
|
prompt: string,
|
||||||
initialQuery = '',
|
initialQuery = '',
|
||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): string {
|
): string {
|
||||||
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', formatRofiPrompt(prompt)];
|
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
args.push('-theme', themePath);
|
args.push('-theme', themePath);
|
||||||
} else {
|
} else {
|
||||||
@@ -119,7 +110,7 @@ export async function promptOptionalJellyfinSearch(
|
|||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (useRofi && commandExists('rofi')) {
|
if (useRofi && commandExists('rofi')) {
|
||||||
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
|
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
rofiArgs.push('-theme', themePath);
|
rofiArgs.push('-theme', themePath);
|
||||||
} else {
|
} else {
|
||||||
@@ -166,7 +157,7 @@ function showRofiIconMenu(
|
|||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): number {
|
): number {
|
||||||
if (entries.length === 0) return -1;
|
if (entries.length === 0) return -1;
|
||||||
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', formatRofiPrompt(prompt)];
|
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
|
||||||
if (initialQuery) rofiArgs.push('-filter', initialQuery);
|
if (initialQuery) rofiArgs.push('-filter', initialQuery);
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
rofiArgs.push('-theme', themePath);
|
rofiArgs.push('-theme', themePath);
|
||||||
@@ -400,7 +391,7 @@ export function showRofiMenu(
|
|||||||
'-dmenu',
|
'-dmenu',
|
||||||
'-i',
|
'-i',
|
||||||
'-p',
|
'-p',
|
||||||
formatRofiPrompt('Select Video'),
|
'Select Video ',
|
||||||
'-show-icons',
|
'-show-icons',
|
||||||
'-theme-str',
|
'-theme-str',
|
||||||
'configuration { font: "Noto Sans CJK JP Regular 8";}',
|
'configuration { font: "Noto Sans CJK JP Regular 8";}',
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
// The engine executes on libsql in production; these merge tests run through
|
import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js';
|
||||||
// that same driver. bun:sqlite is used only to build fixtures and inspect
|
import { createDbSnapshot as createDbSnapshotWith } from '../../src/core/services/stats-sync/shared.js';
|
||||||
// results.
|
import { mergeSnapshotIntoDb as mergeSnapshotIntoDbWith } from '../../src/core/services/stats-sync/merge.js';
|
||||||
import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js';
|
|
||||||
import { mergeSnapshotIntoDb } 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 {
|
import {
|
||||||
createImmersionDbFixture,
|
createImmersionDbFixture,
|
||||||
insertFixtureSession,
|
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
|
// Only the ended session's count is adopted; the active session's slice
|
||||||
// is re-added when that session finalizes and syncs.
|
// is re-added when that session finalizes and syncs.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
queryOne<{ frequency: number }>(
|
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
|
||||||
localPath,
|
?.frequency,
|
||||||
`SELECT frequency FROM imm_words WHERE word = '食べた'`,
|
|
||||||
)?.frequency,
|
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -652,10 +656,8 @@ test('adopted word frequency excludes active-session counts that merge later', (
|
|||||||
assert.equal(second.sessionsAlreadyPresent, 1);
|
assert.equal(second.sessionsAlreadyPresent, 1);
|
||||||
// 1 (ended session) + 4 (finalized session), not 5 + 4 = 9.
|
// 1 (ended session) + 4 (finalized session), not 5 + 4 = 9.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
queryOne<{ frequency: number }>(
|
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
|
||||||
localPath,
|
?.frequency,
|
||||||
`SELECT frequency FROM imm_words WHERE word = '食べた'`,
|
|
||||||
)?.frequency,
|
|
||||||
5,
|
5,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+11
-2
@@ -114,8 +114,17 @@ export interface Args {
|
|||||||
useRofi: boolean;
|
useRofi: boolean;
|
||||||
history: boolean;
|
history: boolean;
|
||||||
sync: boolean;
|
sync: boolean;
|
||||||
/** App-owned sync argv tokens forwarded verbatim to `--sync-cli sync`. */
|
syncHost: string;
|
||||||
syncCliTokens: 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;
|
syncUi: boolean;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
logRotation: LogRotation;
|
logRotation: LogRotation;
|
||||||
|
|||||||
+27
-31
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.0-beta.4",
|
"version": "0.18.0",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||||
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
||||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
||||||
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
|
|
||||||
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
|
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
|
||||||
"compare-yomitan-api:electron": "bun run build:yomitan && bun build scripts/compare-yomitan-api.ts --format=cjs --target=node --outfile dist/scripts/compare-yomitan-api.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/compare-yomitan-api.js",
|
"compare-yomitan-api:electron": "bun run build:yomitan && bun build scripts/compare-yomitan-api.ts --format=cjs --target=node --outfile dist/scripts/compare-yomitan-api.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/compare-yomitan-api.js",
|
||||||
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
||||||
@@ -24,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": "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: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: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:build": "bun run scripts/build-changelog.ts build-release",
|
||||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||||
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
||||||
@@ -48,9 +47,13 @@
|
|||||||
"docs:preview": "bun run --cwd docs-site docs:preview",
|
"docs:preview": "bun run --cwd docs-site docs:preview",
|
||||||
"docs:test": "bun run --cwd docs-site test",
|
"docs:test": "bun run --cwd docs-site test",
|
||||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
"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:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher: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: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: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",
|
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
||||||
@@ -61,13 +64,15 @@
|
|||||||
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
||||||
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
||||||
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
"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:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src",
|
||||||
"test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full",
|
"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": "bun run test:fast",
|
||||||
"test:config": "bun scripts/run-test-lane.mjs config",
|
"test:config": "bun run test:config:src",
|
||||||
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
|
"test:launcher": "bun run test:launcher:src",
|
||||||
"test:subtitle": "bun run test:subtitle: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",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
@@ -82,18 +87,13 @@
|
|||||||
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
|
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.12",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.8.2",
|
||||||
"brace-expansion": "5.0.8",
|
"electron-builder-squirrel-windows": "26.8.2",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
|
||||||
"form-data": "4.0.6",
|
|
||||||
"ip-address": "10.2.0",
|
|
||||||
"js-yaml": "4.3.0",
|
|
||||||
"lodash": "4.18.0",
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.3",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.11"
|
||||||
"tmp": "0.2.7"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"anki",
|
"anki",
|
||||||
@@ -110,24 +110,23 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/geist-mono": "^5.2.7",
|
"@fontsource-variable/geist-mono": "^5.2.7",
|
||||||
"@xhayper/discord-rpc": "^1.3.4",
|
"@xhayper/discord-rpc": "^1.3.3",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.3",
|
||||||
"hono": "^4.12.28",
|
"hono": "^4.12.7",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"koffi": "^2.15.6",
|
"koffi": "^2.15.6",
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "42.2.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.8.2",
|
||||||
"undici": "7.28.0",
|
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.4.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
@@ -260,8 +259,5 @@
|
|||||||
"to": "launcher/subminer"
|
"to": "launcher/subminer"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"patchedDependencies": {
|
|
||||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
},
|
|
||||||
@@ -254,8 +254,6 @@ function M.create(ctx)
|
|||||||
return { "--open-runtime-options" }
|
return { "--open-runtime-options" }
|
||||||
elseif action_id == "openJimaku" then
|
elseif action_id == "openJimaku" then
|
||||||
return { "--open-jimaku" }
|
return { "--open-jimaku" }
|
||||||
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
|
|
||||||
return { "--open-tsukihime" }
|
|
||||||
elseif action_id == "openYoutubePicker" then
|
elseif action_id == "openYoutubePicker" then
|
||||||
return { "--open-youtube-picker" }
|
return { "--open-youtube-picker" }
|
||||||
elseif action_id == "openSessionHelp" then
|
elseif action_id == "openSessionHelp" then
|
||||||
|
|||||||
+58
-54
@@ -1,69 +1,73 @@
|
|||||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
> 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
|
## Highlights
|
||||||
### Added
|
### Added
|
||||||
|
- **Watch History Browser**
|
||||||
- **Sync Stats & History**
|
- 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.
|
||||||
- New **Sync Stats & History** window (tray menu) and `subminer sync <host>` command keep mining stats and watch history in sync between machines over SSH, with saved devices, per-host sync direction, and live stage-by-stage progress.
|
- The rofi picker now shows AniList cover art for each show, making it easier to spot the right title at a glance.
|
||||||
- Merges are safe to repeat: data combines without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
|
- **Card Audio Normalization**
|
||||||
- Manual snapshot tools (create, merge, reveal, delete) and connection testing cover one-off transfers; Windows machines running the built-in OpenSSH Server work as sync remotes too, with no setup needed beyond SSH access. Power users can script transfers directly with `--push`/`--pull`, `--check`, `--snapshot`/`--merge`, and `--json` flags.
|
- 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.
|
||||||
- **TsukiHime Subtitle Downloads**
|
|
||||||
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow.
|
|
||||||
- Press `Ctrl+Shift+T` to search by tabs for the primary and secondary languages; the matching release is found automatically from the video filename and loads straight into mpv, no API key required.
|
|
||||||
|
|
||||||
- **Post-Playback History Menu**
|
|
||||||
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
|
|
||||||
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
|
|
||||||
- The menu shown right after picking a series from `subminer -H` now offers the previous episode too, matching the post-playback menu.
|
|
||||||
|
|
||||||
- **Known-Word Highlighting by Anki Maturity**
|
|
||||||
- Subtitle highlights for known words can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Enable it with `ankiConnect.knownWords.maturityEnabled`, or toggle it live during a session.
|
|
||||||
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
|
|
||||||
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **New App Icon**
|
||||||
- **Clipboard-Video Shortcut**
|
- SubMiner now ships pixel-art submarine artwork contributed by an anonymous community member.
|
||||||
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of being fixed.
|
- 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
|
### Fixed
|
||||||
|
- **Character Name Highlighting in Subtitles**
|
||||||
- **Word Highlighting Accuracy**
|
- 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 several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
- 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.
|
||||||
- **Character Dictionary Season Overrides**
|
- No action needed — existing data upgrades automatically the next time a matching name is seen.
|
||||||
- Manual AniList overrides for a series now stay in effect for every episode in the same season folder, even when individual episode filenames produce different automatic guesses.
|
- **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).
|
||||||
- **Startup Playback Pausing Too Early**
|
- 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).
|
||||||
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
- Single-kana grammar tokens (particles like よ, え) no longer borrow an unrelated card's reading and get falsely painted as known.
|
||||||
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
|
- Stats sessions now correctly reflect known-word counts again after the reading-aware matching upgrade, instead of showing 0 everywhere.
|
||||||
|
- **Annotation Highlighting Refinements**
|
||||||
- **Linux AppImage Crash Notification on Quit**
|
- Restored frequency/JLPT highlighting and vocabulary-stat counting for words like 確かに and やはり, which were wrongly treated as grammar noise.
|
||||||
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
- Kanji nouns that MeCab tags as "non-independent" (e.g. 日, 点, 以外) also keep their highlighting and stats counting again.
|
||||||
- If needed, the mount-keepalive behavior behind this fix can be disabled with `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1`.
|
- 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**
|
||||||
- **AnkiConnect Proxy Port Conflict**
|
- 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.
|
||||||
- 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.
|
- **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.
|
||||||
- **Stats & Settings Reliability**
|
- 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 session stats reporting zero known words after the known-word cache gained maturity tiers.
|
- 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.
|
||||||
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
- **Secondary Subtitles**
|
||||||
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
- 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**
|
||||||
- **Rofi Menu Prompt Spacing**
|
- Fixed direct YouTube stream extraction occasionally corrupting the stream URL and causing failed audio/video capture.
|
||||||
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
|
- **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
|
## What's Changed
|
||||||
|
|
||||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
- fix(youtube): parse mpv EDL stream URLs with byte-length guards by @ksyasuda in #134
|
||||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
- Normalize generated Anki audio by default by @ksyasuda in #135
|
||||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
- feat(launcher): add -H/--history command to browse local watch history by @ksyasuda in #136
|
||||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
- fix(overlay): prevent field grouping modal from freezing overlay on Hyprland by @ksyasuda in #138
|
||||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
- fix(overlay): collapse karaoke syllable spam in secondary subtitles by @ksyasuda in #139
|
||||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
- 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
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
@@ -16,6 +16,15 @@ export const testLanes: Record<string, TestLane> = {
|
|||||||
'bun-src-full': {
|
'bun-src-full': {
|
||||||
roots: ['src'],
|
roots: ['src'],
|
||||||
include: ['.test.ts', '.type-test.ts'],
|
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: {
|
config: {
|
||||||
roots: ['src/config'],
|
roots: ['src/config'],
|
||||||
|
|||||||
@@ -237,14 +237,6 @@ local ctx = {
|
|||||||
actionType = "session-action",
|
actionType = "session-action",
|
||||||
actionId = "openPlaylistBrowser",
|
actionId = "openPlaylistBrowser",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key = {
|
|
||||||
code = "KeyT",
|
|
||||||
modifiers = { "ctrl", "alt" },
|
|
||||||
},
|
|
||||||
actionType = "session-action",
|
|
||||||
actionId = "openAnimetosho",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key = {
|
key = {
|
||||||
code = "KeyH",
|
code = "KeyH",
|
||||||
@@ -395,7 +387,6 @@ end
|
|||||||
local expected_cli_bindings = {
|
local expected_cli_bindings = {
|
||||||
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
|
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
|
||||||
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
||||||
{ keys = "Ctrl+Alt+t", flag = "--open-tsukihime" },
|
|
||||||
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
|
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
|
||||||
{ keys = "Ctrl+L", flag = "--play-next-subtitle" },
|
{ keys = "Ctrl+L", flag = "--play-next-subtitle" },
|
||||||
{ keys = "w", flag = "--mark-watched" },
|
{ keys = "w", flag = "--mark-watched" },
|
||||||
|
|||||||
@@ -1,593 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import process from 'node:process';
|
|
||||||
|
|
||||||
import {
|
|
||||||
KnownWordCacheManager,
|
|
||||||
getKnownWordCacheLifecycleConfig,
|
|
||||||
} from '../src/anki-integration/known-word-cache.js';
|
|
||||||
import { getMatureIntervalThresholdDays } from '../src/anki-integration/known-word-maturity.js';
|
|
||||||
import { resolveConfigDir } from '../src/config/path-resolution.js';
|
|
||||||
import { ConfigService } from '../src/config/service.js';
|
|
||||||
import { parseSubtitleCues } from '../src/core/services/subtitle-cue-parser.js';
|
|
||||||
import { createTokenizerDepsRuntime, tokenizeSubtitle } from '../src/core/services/tokenizer.js';
|
|
||||||
import {
|
|
||||||
resolveCompleteTokenReading,
|
|
||||||
resolveKnownWordReadingForMatch,
|
|
||||||
resolveKnownWordText,
|
|
||||||
} from '../src/core/services/tokenizer/annotation-stage.js';
|
|
||||||
import { MecabTokenizer } from '../src/mecab-tokenizer.js';
|
|
||||||
import type { MergedToken } from '../src/types.js';
|
|
||||||
import type { KnownWordMaturityTier } from '../src/types/subtitle.js';
|
|
||||||
import {
|
|
||||||
createYomitanRuntimeStateWithSearch,
|
|
||||||
destroyParserWindow,
|
|
||||||
loadElectronModule,
|
|
||||||
withTimeout,
|
|
||||||
type YomitanRuntimeState,
|
|
||||||
} from './yomitan-script-runtime.js';
|
|
||||||
|
|
||||||
interface CliOptions {
|
|
||||||
input: string;
|
|
||||||
configDir?: string;
|
|
||||||
yomitanUserDataPath?: string;
|
|
||||||
yomitanExtensionPath?: string;
|
|
||||||
limit: number;
|
|
||||||
audit: boolean;
|
|
||||||
refresh: boolean;
|
|
||||||
json: boolean;
|
|
||||||
quiet: boolean;
|
|
||||||
profileCopy: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TierOrFallback = KnownWordMaturityTier | 'known-no-tier';
|
|
||||||
|
|
||||||
interface TokenReport {
|
|
||||||
cueIndex: number;
|
|
||||||
startTime: number;
|
|
||||||
surface: string;
|
|
||||||
headword: string;
|
|
||||||
reading: string;
|
|
||||||
tier: TierOrFallback;
|
|
||||||
noteIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuditMismatch extends TokenReport {
|
|
||||||
liveTier: KnownWordMaturityTier | 'no-notes';
|
|
||||||
intervals: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TIERS: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
|
|
||||||
const FALLBACK_TIER_COLORS: Record<KnownWordMaturityTier, string> = {
|
|
||||||
new: '#ee99a0',
|
|
||||||
learning: '#b7bdf8',
|
|
||||||
young: '#91d7e3',
|
|
||||||
mature: '#a6da95',
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseCliArgs(argv: string[]): CliOptions {
|
|
||||||
const options: CliOptions = {
|
|
||||||
input: '',
|
|
||||||
limit: 0,
|
|
||||||
audit: false,
|
|
||||||
refresh: false,
|
|
||||||
json: false,
|
|
||||||
quiet: false,
|
|
||||||
profileCopy: false,
|
|
||||||
};
|
|
||||||
const rest: string[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
|
||||||
const arg = argv[i]!;
|
|
||||||
const takeValue = (flag: string): string => {
|
|
||||||
const next = argv[i + 1];
|
|
||||||
if (!next) {
|
|
||||||
throw new Error(`Missing value for ${flag}`);
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (arg === '--help' || arg === '-h') {
|
|
||||||
process.stdout.write(`${usage()}\n`);
|
|
||||||
process.exit(0);
|
|
||||||
} else if (arg === '--input') {
|
|
||||||
options.input = takeValue(arg);
|
|
||||||
} else if (arg === '--config-dir') {
|
|
||||||
options.configDir = takeValue(arg);
|
|
||||||
} else if (arg === '--yomitan-user-data') {
|
|
||||||
options.yomitanUserDataPath = takeValue(arg);
|
|
||||||
} else if (arg === '--yomitan-extension-path') {
|
|
||||||
options.yomitanExtensionPath = takeValue(arg);
|
|
||||||
} else if (arg === '--limit') {
|
|
||||||
options.limit = Math.max(0, Number.parseInt(takeValue(arg), 10) || 0);
|
|
||||||
} else if (arg === '--audit') {
|
|
||||||
options.audit = true;
|
|
||||||
} else if (arg === '--refresh') {
|
|
||||||
options.refresh = true;
|
|
||||||
} else if (arg === '--json') {
|
|
||||||
options.json = true;
|
|
||||||
} else if (arg === '--quiet') {
|
|
||||||
options.quiet = true;
|
|
||||||
} else if (arg === '--profile-copy') {
|
|
||||||
options.profileCopy = true;
|
|
||||||
} else if (arg === '--') {
|
|
||||||
// `bun run <script> -- --flag ...` forwards the separator too.
|
|
||||||
continue;
|
|
||||||
} else if (arg.startsWith('--')) {
|
|
||||||
throw new Error(`Unknown flag: ${arg}`);
|
|
||||||
} else {
|
|
||||||
rest.push(arg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.input && rest.length > 0) {
|
|
||||||
options.input = rest.join(' ');
|
|
||||||
}
|
|
||||||
if (!options.input) {
|
|
||||||
throw new Error(`No subtitle file given.\n${usage()}`);
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage(): string {
|
|
||||||
return [
|
|
||||||
'Usage: verify-known-word-highlights <subtitle.srt|.ass> [flags]',
|
|
||||||
'',
|
|
||||||
' --limit <n> Only check the first n cues (default: all)',
|
|
||||||
' --audit Re-derive every highlighted tier from live Anki card data',
|
|
||||||
' --refresh Force a known-word cache refresh before checking',
|
|
||||||
' --json Emit a machine-readable report',
|
|
||||||
' --quiet Skip the per-line colored dump',
|
|
||||||
' --profile-copy Copy the Yomitan profile to a scratch dir so this can run',
|
|
||||||
' while SubMiner is open (Electron locks the userData dir)',
|
|
||||||
' --config-dir <dir> SubMiner config dir (default: auto-detected)',
|
|
||||||
' --yomitan-user-data <dir> Electron userData dir holding the Yomitan profile',
|
|
||||||
' --yomitan-extension-path <dir>',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANSI_RESET = '\u001b[0m';
|
|
||||||
|
|
||||||
function colorize(text: string, hex: string): string {
|
|
||||||
const normalized = hex.trim().replace(/^#/, '');
|
|
||||||
const expanded =
|
|
||||||
normalized.length === 3
|
|
||||||
? normalized
|
|
||||||
.split('')
|
|
||||||
.map((char) => `${char}${char}`)
|
|
||||||
.join('')
|
|
||||||
: normalized;
|
|
||||||
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
const r = Number.parseInt(expanded.slice(0, 2), 16);
|
|
||||||
const g = Number.parseInt(expanded.slice(2, 4), 16);
|
|
||||||
const b = Number.parseInt(expanded.slice(4, 6), 16);
|
|
||||||
return `\u001b[38;2;${r};${g};${b}m${text}${ANSI_RESET}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(seconds: number): string {
|
|
||||||
const total = Math.max(0, Math.floor(seconds));
|
|
||||||
const mm = String(Math.floor(total / 60)).padStart(2, '0');
|
|
||||||
const ss = String(total % 60).padStart(2, '0');
|
|
||||||
return `${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user's real config dir is copied into a scratch dir so this read-only
|
|
||||||
// check can never rewrite config.jsonc (ConfigService migrates on load) or the
|
|
||||||
// live known-word cache (a --refresh persists tier data).
|
|
||||||
function createScratchState(configDir: string): { dir: string; cachePath: string } {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-verify-'));
|
|
||||||
for (const fileName of ['config.jsonc', 'config.json']) {
|
|
||||||
const source = path.join(configDir, fileName);
|
|
||||||
if (fs.existsSync(source)) {
|
|
||||||
fs.copyFileSync(source, path.join(dir, fileName));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cachePath = path.join(dir, 'known-words-cache.json');
|
|
||||||
const liveCachePath = path.join(configDir, 'known-words-cache.json');
|
|
||||||
if (fs.existsSync(liveCachePath)) {
|
|
||||||
fs.copyFileSync(liveCachePath, cachePath);
|
|
||||||
}
|
|
||||||
return { dir, cachePath };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Electron locks a userData dir, so the Yomitan profile can't be shared with a
|
|
||||||
// running SubMiner. Copying the dictionary-bearing parts of the profile lets
|
|
||||||
// this check run mid-session (IndexedDB alone is often over 1 GB).
|
|
||||||
const YOMITAN_PROFILE_DIRS = [
|
|
||||||
'extensions',
|
|
||||||
'IndexedDB',
|
|
||||||
'Local Extension Settings',
|
|
||||||
'Local Storage',
|
|
||||||
];
|
|
||||||
|
|
||||||
function copyYomitanProfile(sourceUserDataPath: string): string {
|
|
||||||
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-profile-'));
|
|
||||||
for (const name of YOMITAN_PROFILE_DIRS) {
|
|
||||||
const source = path.join(sourceUserDataPath, name);
|
|
||||||
if (fs.existsSync(source)) {
|
|
||||||
fs.cpSync(source, path.join(target, name), { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPersistedCacheScope(cachePath: string): string | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as { scope?: unknown };
|
|
||||||
return typeof parsed.scope === 'string' ? parsed.scope : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANKI_REQUEST_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
function createAnkiClient(url: string) {
|
|
||||||
const request = async (action: string, params: unknown): Promise<unknown> => {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action, version: 6, params }),
|
|
||||||
signal: AbortSignal.timeout(ANKI_REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`AnkiConnect ${action}: HTTP ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as { result: unknown; error: string | null };
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(`AnkiConnect ${action}: ${payload.error}`);
|
|
||||||
}
|
|
||||||
return payload.result;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
request,
|
|
||||||
findNotes: (query: string) => request('findNotes', { query }),
|
|
||||||
notesInfo: (noteIds: number[]) => request('notesInfo', { notes: noteIds }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTokenMatch(
|
|
||||||
token: MergedToken,
|
|
||||||
cache: KnownWordCacheManager,
|
|
||||||
matchMode: 'surface' | 'headword',
|
|
||||||
): { tier: KnownWordMaturityTier | null; noteIds: Set<number> } {
|
|
||||||
const matchText = resolveKnownWordText(token.surface, token.headword, matchMode);
|
|
||||||
const matchReading = resolveKnownWordReadingForMatch(token, matchMode);
|
|
||||||
const primaryTier = matchText ? cache.getKnownWordTier(matchText, matchReading) : null;
|
|
||||||
if (primaryTier) {
|
|
||||||
return { tier: primaryTier, noteIds: cache.getKnownWordMatchNoteIds(matchText, matchReading) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackReading = resolveCompleteTokenReading(token);
|
|
||||||
if (!fallbackReading || fallbackReading === matchText.trim()) {
|
|
||||||
return {
|
|
||||||
tier: null,
|
|
||||||
noteIds: matchText ? cache.getKnownWordMatchNoteIds(matchText, matchReading) : new Set(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const fallbackOptions = { allowReadingOnlyMatch: false } as const;
|
|
||||||
return {
|
|
||||||
tier: cache.getKnownWordTier(fallbackReading, undefined, fallbackOptions),
|
|
||||||
noteIds: cache.getKnownWordMatchNoteIds(fallbackReading, undefined, fallbackOptions),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ground truth straight from card data, independent of the Anki search filters
|
|
||||||
// the cache refresh uses (prop:ivl / is:learn).
|
|
||||||
function classifyCardsIntoTier(
|
|
||||||
cards: Array<{ interval: number; queue: number; type: number }>,
|
|
||||||
thresholdDays: number,
|
|
||||||
): KnownWordMaturityTier {
|
|
||||||
if (cards.some((card) => card.interval >= thresholdDays)) return 'mature';
|
|
||||||
if (cards.some((card) => card.interval >= 1)) return 'young';
|
|
||||||
if (
|
|
||||||
cards.some((card) => card.type === 1 || card.type === 3 || card.queue === 1 || card.queue === 4)
|
|
||||||
)
|
|
||||||
return 'learning';
|
|
||||||
return 'new';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function auditTokens(
|
|
||||||
reports: TokenReport[],
|
|
||||||
client: ReturnType<typeof createAnkiClient>,
|
|
||||||
thresholdDays: number,
|
|
||||||
): Promise<{ mismatches: AuditMismatch[]; auditedNotes: number }> {
|
|
||||||
const noteIds = [...new Set(reports.flatMap((report) => report.noteIds))];
|
|
||||||
const cardIdsByNote = new Map<number, number[]>();
|
|
||||||
for (let i = 0; i < noteIds.length; i += 500) {
|
|
||||||
const infos = (await client.notesInfo(noteIds.slice(i, i + 500))) as Array<{
|
|
||||||
noteId: number;
|
|
||||||
cards?: number[];
|
|
||||||
}>;
|
|
||||||
for (const info of infos) {
|
|
||||||
cardIdsByNote.set(info.noteId, info.cards ?? []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allCardIds = [...cardIdsByNote.values()].flat();
|
|
||||||
const cardById = new Map<number, { interval: number; queue: number; type: number }>();
|
|
||||||
for (let i = 0; i < allCardIds.length; i += 500) {
|
|
||||||
const infos = (await client.request('cardsInfo', {
|
|
||||||
cards: allCardIds.slice(i, i + 500),
|
|
||||||
})) as Array<{ cardId: number; interval: number; queue: number; type: number }>;
|
|
||||||
for (const info of infos) {
|
|
||||||
cardById.set(info.cardId, {
|
|
||||||
interval: info.interval,
|
|
||||||
queue: info.queue,
|
|
||||||
type: info.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mismatches: AuditMismatch[] = [];
|
|
||||||
for (const report of reports) {
|
|
||||||
const cards = report.noteIds
|
|
||||||
.flatMap((noteId) => cardIdsByNote.get(noteId) ?? [])
|
|
||||||
.map((cardId) => cardById.get(cardId))
|
|
||||||
.filter((card): card is { interval: number; queue: number; type: number } => Boolean(card));
|
|
||||||
const liveTier = cards.length === 0 ? 'no-notes' : classifyCardsIntoTier(cards, thresholdDays);
|
|
||||||
if (liveTier !== report.tier) {
|
|
||||||
mismatches.push({
|
|
||||||
...report,
|
|
||||||
liveTier,
|
|
||||||
intervals: cards.map((card) => card.interval),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { mismatches, auditedNotes: noteIds.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const args = parseCliArgs(process.argv.slice(2));
|
|
||||||
let electronModule: typeof import('electron') | null = null;
|
|
||||||
let yomitanState: YomitanRuntimeState | null = null;
|
|
||||||
let scratchDir: string | null = null;
|
|
||||||
let profileCopyDir: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configDir =
|
|
||||||
args.configDir ??
|
|
||||||
resolveConfigDir({
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
||||||
existsSync: fs.existsSync,
|
|
||||||
});
|
|
||||||
const scratch = createScratchState(configDir);
|
|
||||||
scratchDir = scratch.dir;
|
|
||||||
const config = new ConfigService(scratch.dir).getConfig();
|
|
||||||
const ankiConfig = config.ankiConnect;
|
|
||||||
const matchMode = ankiConfig.knownWords?.matchMode === 'surface' ? 'surface' : 'headword';
|
|
||||||
const thresholdDays = getMatureIntervalThresholdDays(ankiConfig);
|
|
||||||
const client = createAnkiClient(ankiConfig.url);
|
|
||||||
const cacheScopeKey = getKnownWordCacheLifecycleConfig(ankiConfig);
|
|
||||||
|
|
||||||
const cache = new KnownWordCacheManager({
|
|
||||||
client: { findNotes: (query) => client.findNotes(query), notesInfo: client.notesInfo },
|
|
||||||
getConfig: () => ankiConfig,
|
|
||||||
knownWordCacheStatePath: scratch.cachePath,
|
|
||||||
showStatusNotification: () => {},
|
|
||||||
});
|
|
||||||
// A cache whose persisted scope key no longer matches the config is
|
|
||||||
// discarded on load, so every token would come back unknown.
|
|
||||||
if (!args.refresh && readPersistedCacheScope(scratch.cachePath) !== cacheScopeKey) {
|
|
||||||
process.stderr.write(
|
|
||||||
'warning: the persisted known-word cache was built under different settings and will be ' +
|
|
||||||
'ignored (the app refetches it on its next refresh). Re-run with --refresh to fetch tiers now.\n',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// startLifecycle loads the persisted cache; the refresh timer it arms is
|
|
||||||
// cleared before the event loop can run it.
|
|
||||||
cache.startLifecycle();
|
|
||||||
cache.stopLifecycle();
|
|
||||||
if (args.refresh) {
|
|
||||||
await cache.refresh(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cues = parseSubtitleCues(fs.readFileSync(args.input, 'utf-8'), args.input);
|
|
||||||
const selectedCues = args.limit > 0 ? cues.slice(0, args.limit) : cues;
|
|
||||||
|
|
||||||
const mecabTokenizer = new MecabTokenizer();
|
|
||||||
if (!(await mecabTokenizer.checkAvailability())) {
|
|
||||||
throw new Error('MeCab is not available; tokenization would not match the overlay.');
|
|
||||||
}
|
|
||||||
electronModule = await loadElectronModule();
|
|
||||||
const userDataPath = args.profileCopy
|
|
||||||
? copyYomitanProfile(args.yomitanUserDataPath ?? configDir)
|
|
||||||
: (args.yomitanUserDataPath ?? configDir);
|
|
||||||
profileCopyDir = args.profileCopy ? userDataPath : null;
|
|
||||||
if (electronModule?.app && typeof electronModule.app.setPath === 'function') {
|
|
||||||
electronModule.app.setPath('userData', userDataPath);
|
|
||||||
}
|
|
||||||
yomitanState = await createYomitanRuntimeStateWithSearch(
|
|
||||||
userDataPath,
|
|
||||||
args.yomitanExtensionPath,
|
|
||||||
);
|
|
||||||
if (!yomitanState.available) {
|
|
||||||
throw new Error(`Yomitan tokenizer unavailable: ${yomitanState.note ?? 'unknown reason'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deps = createTokenizerDepsRuntime({
|
|
||||||
getYomitanExt: () => yomitanState!.yomitanExt as never,
|
|
||||||
getYomitanSession: () => yomitanState!.yomitanSession as never,
|
|
||||||
getYomitanParserWindow: () => yomitanState!.parserWindow as never,
|
|
||||||
setYomitanParserWindow: (window) => {
|
|
||||||
yomitanState!.parserWindow = window;
|
|
||||||
},
|
|
||||||
getYomitanParserReadyPromise: () => yomitanState!.parserReadyPromise as never,
|
|
||||||
setYomitanParserReadyPromise: (promise) => {
|
|
||||||
yomitanState!.parserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
getYomitanParserInitPromise: () => yomitanState!.parserInitPromise as never,
|
|
||||||
setYomitanParserInitPromise: (promise) => {
|
|
||||||
yomitanState!.parserInitPromise = promise;
|
|
||||||
},
|
|
||||||
isKnownWord: (text, reading, options) => cache.isKnownWord(text, reading, options),
|
|
||||||
getKnownWordTier: (text, reading, options) => cache.getKnownWordTier(text, reading, options),
|
|
||||||
getKnownWordMatchMode: () => matchMode,
|
|
||||||
getKnownWordsEnabled: () => true,
|
|
||||||
// Other annotation layers are off so every colored token below is a
|
|
||||||
// known-word decision, not an N+1/frequency/name override.
|
|
||||||
getNPlusOneEnabled: () => false,
|
|
||||||
getNameMatchEnabled: () => false,
|
|
||||||
getJlptEnabled: () => false,
|
|
||||||
getFrequencyDictionaryEnabled: () => false,
|
|
||||||
getJlptLevel: () => null,
|
|
||||||
getMecabTokenizer: () => ({ tokenize: (text: string) => mecabTokenizer.tokenize(text) }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const styleColors = {
|
|
||||||
...FALLBACK_TIER_COLORS,
|
|
||||||
...(config.subtitleStyle?.knownWordMaturityColors ?? {}),
|
|
||||||
} as Record<KnownWordMaturityTier, string>;
|
|
||||||
const knownWordColor = config.subtitleStyle?.knownWordColor ?? '#a6da95';
|
|
||||||
|
|
||||||
const reports: TokenReport[] = [];
|
|
||||||
const tierCounts: Record<string, number> = {};
|
|
||||||
let knownTokens = 0;
|
|
||||||
let totalTokens = 0;
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
for (const [cueIndex, cue] of selectedCues.entries()) {
|
|
||||||
const { text, tokens } = await withTimeout(
|
|
||||||
tokenizeSubtitle(cue.text, deps),
|
|
||||||
20_000,
|
|
||||||
`Tokenizer (cue ${cueIndex + 1})`,
|
|
||||||
);
|
|
||||||
if (!tokens || tokens.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cursor = 0;
|
|
||||||
let rendered = '';
|
|
||||||
const ordered = [...tokens].sort((a, b) => (a.startPos ?? 0) - (b.startPos ?? 0));
|
|
||||||
for (const token of ordered) {
|
|
||||||
totalTokens += 1;
|
|
||||||
const start = Math.min(Math.max(0, token.startPos ?? 0), text.length);
|
|
||||||
const end = Math.min(Math.max(start, token.endPos ?? start), text.length);
|
|
||||||
if (start > cursor) {
|
|
||||||
rendered += text.slice(cursor, start);
|
|
||||||
}
|
|
||||||
const surfaceText = text.slice(start, end);
|
|
||||||
cursor = end;
|
|
||||||
|
|
||||||
if (!token.isKnown) {
|
|
||||||
rendered += surfaceText;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
knownTokens += 1;
|
|
||||||
const tier: TierOrFallback = token.knownMaturity ?? 'known-no-tier';
|
|
||||||
tierCounts[tier] = (tierCounts[tier] ?? 0) + 1;
|
|
||||||
rendered += colorize(
|
|
||||||
surfaceText,
|
|
||||||
tier === 'known-no-tier' ? knownWordColor : styleColors[tier],
|
|
||||||
);
|
|
||||||
reports.push({
|
|
||||||
cueIndex,
|
|
||||||
startTime: cue.startTime,
|
|
||||||
surface: token.surface,
|
|
||||||
headword: token.headword,
|
|
||||||
reading: token.reading,
|
|
||||||
tier,
|
|
||||||
noteIds: [...resolveTokenMatch(token, cache, matchMode).noteIds],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
rendered += text.slice(cursor);
|
|
||||||
lines.push(`${formatTimestamp(cue.startTime)} ${rendered}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalTokens === 0 && selectedCues.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
'Yomitan returned no tokens. SubMiner is probably running and holding the Electron ' +
|
|
||||||
'profile lock - quit it, or re-run with --profile-copy.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let audit: { mismatches: AuditMismatch[]; auditedNotes: number } | null = null;
|
|
||||||
if (args.audit) {
|
|
||||||
audit = await auditTokens(reports, client, thresholdDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.json) {
|
|
||||||
process.stdout.write(
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
input: args.input,
|
|
||||||
cues: selectedCues.length,
|
|
||||||
totalTokens,
|
|
||||||
knownTokens,
|
|
||||||
tierCounts,
|
|
||||||
matureThresholdDays: thresholdDays,
|
|
||||||
matchMode,
|
|
||||||
tokens: reports,
|
|
||||||
audit,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.quiet) {
|
|
||||||
process.stdout.write(`${lines.join('\n')}\n\n`);
|
|
||||||
}
|
|
||||||
process.stdout.write(
|
|
||||||
[
|
|
||||||
`file : ${args.input}`,
|
|
||||||
`cues checked : ${selectedCues.length} of ${cues.length}`,
|
|
||||||
`tokens : ${totalTokens} (${knownTokens} known, ${totalTokens - knownTokens} unknown)`,
|
|
||||||
`match mode : ${matchMode} mature threshold: ${thresholdDays}d`,
|
|
||||||
'',
|
|
||||||
'known-token tiers:',
|
|
||||||
...TIERS.map(
|
|
||||||
(tier) =>
|
|
||||||
` ${colorize(tier.padEnd(9), styleColors[tier])} ${String(tierCounts[tier] ?? 0).padStart(5)}` +
|
|
||||||
` ${styleColors[tier]}`,
|
|
||||||
),
|
|
||||||
` ${colorize('no tier'.padEnd(9), knownWordColor)} ${String(tierCounts['known-no-tier'] ?? 0).padStart(5)} ${knownWordColor} (falls back to knownWordColor)`,
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (audit) {
|
|
||||||
process.stdout.write(
|
|
||||||
`audit: ${reports.length - audit.mismatches.length}/${reports.length} highlighted tokens agree with live Anki card data ` +
|
|
||||||
`(${audit.auditedNotes} notes)\n`,
|
|
||||||
);
|
|
||||||
for (const mismatch of audit.mismatches.slice(0, 40)) {
|
|
||||||
process.stdout.write(
|
|
||||||
` ${formatTimestamp(mismatch.startTime)} ${mismatch.surface} (${mismatch.headword}) ` +
|
|
||||||
`shown=${mismatch.tier} live=${mismatch.liveTier} ivl=[${mismatch.intervals.join(', ')}] ` +
|
|
||||||
`notes=[${mismatch.noteIds.join(', ')}]\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (audit.mismatches.length > 40) {
|
|
||||||
process.stdout.write(` ... ${audit.mismatches.length - 40} more\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
destroyParserWindow(yomitanState?.parserWindow ?? null);
|
|
||||||
for (const dir of [scratchDir, profileCopyDir]) {
|
|
||||||
if (dir) {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (electronModule?.app) {
|
|
||||||
electronModule.app.quit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => {
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(`Error: ${(error as Error).message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { resolveYomitanExtensionPath as resolveBuiltYomitanExtensionPath } from '../src/core/services/yomitan-extension-paths.js';
|
|
||||||
|
|
||||||
// Yomitan bootstrap for CLI scripts that need the app's real tokenizer. Mirrors
|
|
||||||
// what scripts/get_frequency.ts does inline; new scripts should import this.
|
|
||||||
export interface YomitanRuntimeState {
|
|
||||||
yomitanExt: unknown | null;
|
|
||||||
yomitanSession: unknown | null;
|
|
||||||
parserWindow: unknown | null;
|
|
||||||
parserReadyPromise: Promise<void> | null;
|
|
||||||
parserInitPromise: Promise<boolean> | null;
|
|
||||||
available: boolean;
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
||||||
}, timeoutMs);
|
|
||||||
promise
|
|
||||||
.then((value) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(value);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function destroyParserWindow(window: unknown): void {
|
|
||||||
if (!window || typeof window !== 'object') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const candidate = window as { isDestroyed?: () => boolean; destroy?: () => void };
|
|
||||||
if (typeof candidate.isDestroyed !== 'function' || typeof candidate.destroy !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!candidate.isDestroyed()) {
|
|
||||||
candidate.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadElectronModule(): Promise<typeof import('electron') | null> {
|
|
||||||
try {
|
|
||||||
const electronImport = await import('electron');
|
|
||||||
return (electronImport.default ?? electronImport) as typeof import('electron');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createYomitanRuntimeState(
|
|
||||||
userDataPath: string,
|
|
||||||
extensionPath?: string,
|
|
||||||
): Promise<YomitanRuntimeState> {
|
|
||||||
const state: YomitanRuntimeState = {
|
|
||||||
yomitanExt: null,
|
|
||||||
yomitanSession: null,
|
|
||||||
parserWindow: null,
|
|
||||||
parserReadyPromise: null,
|
|
||||||
parserInitPromise: null,
|
|
||||||
available: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const electronImport = await loadElectronModule();
|
|
||||||
if (
|
|
||||||
!electronImport ||
|
|
||||||
!electronImport.app ||
|
|
||||||
typeof electronImport.app.whenReady !== 'function' ||
|
|
||||||
!electronImport.session
|
|
||||||
) {
|
|
||||||
state.note = electronImport
|
|
||||||
? 'electron runtime not available in this process'
|
|
||||||
: 'electron import failed';
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await electronImport.app.whenReady();
|
|
||||||
const loadYomitanExtension = (await import('../src/core/services/yomitan-extension-loader.js'))
|
|
||||||
.loadYomitanExtension as (options: {
|
|
||||||
userDataPath: string;
|
|
||||||
extensionPath?: string;
|
|
||||||
getYomitanParserWindow: () => unknown;
|
|
||||||
setYomitanParserWindow: (window: unknown) => void;
|
|
||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
|
||||||
setYomitanExtension: (extension: unknown) => void;
|
|
||||||
setYomitanSession: (session: unknown) => void;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
|
|
||||||
const extension = await loadYomitanExtension({
|
|
||||||
userDataPath,
|
|
||||||
extensionPath,
|
|
||||||
getYomitanParserWindow: () => state.parserWindow,
|
|
||||||
setYomitanParserWindow: (window) => {
|
|
||||||
state.parserWindow = window;
|
|
||||||
},
|
|
||||||
setYomitanParserReadyPromise: (promise) => {
|
|
||||||
state.parserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
setYomitanParserInitPromise: (promise) => {
|
|
||||||
state.parserInitPromise = promise;
|
|
||||||
},
|
|
||||||
setYomitanExtension: (loaded) => {
|
|
||||||
state.yomitanExt = loaded;
|
|
||||||
},
|
|
||||||
setYomitanSession: (nextSession) => {
|
|
||||||
state.yomitanSession = nextSession;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!extension) {
|
|
||||||
state.note = 'yomitan extension is not available';
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.yomitanExt = extension;
|
|
||||||
state.available = true;
|
|
||||||
return state;
|
|
||||||
} catch (error) {
|
|
||||||
state.note = error instanceof Error ? error.message : 'failed to initialize yomitan extension';
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createYomitanRuntimeStateWithSearch(
|
|
||||||
userDataPath: string,
|
|
||||||
extensionPath?: string,
|
|
||||||
): Promise<YomitanRuntimeState> {
|
|
||||||
const resolvedExtensionPath = resolveBuiltYomitanExtensionPath({
|
|
||||||
explicitPath: extensionPath,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resolvedExtensionPath) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(path.join(resolvedExtensionPath, 'manifest.json'))) {
|
|
||||||
const state = await createYomitanRuntimeState(userDataPath, resolvedExtensionPath);
|
|
||||||
if (!state.available && !state.note) {
|
|
||||||
state.note = `Failed to load yomitan extension at ${resolvedExtensionPath}`;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to the unconstrained loader below
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No usable manifest at the resolved path, so let the loader search on its own.
|
|
||||||
return createYomitanRuntimeState(userDataPath);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import http from 'node:http';
|
|
||||||
import { once } from 'node:events';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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);
|
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 () => {
|
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
|
||||||
import { MpvClient } from './types/runtime';
|
import { MpvClient } from './types/runtime';
|
||||||
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
|
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
|
||||||
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
|
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
|
||||||
@@ -462,7 +461,6 @@ export class AnkiIntegration {
|
|||||||
getDeck: () => this.config.deck,
|
getDeck: () => this.config.deck,
|
||||||
findNotes: async (query, options) =>
|
findNotes: async (query, options) =>
|
||||||
(await this.client.findNotes(query, options)) as number[],
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
notifyUnavailable: (message) => this.showStatusNotification(message),
|
|
||||||
logInfo: (message, ...args) => log.info(message, ...args),
|
logInfo: (message, ...args) => log.info(message, ...args),
|
||||||
logWarn: (message, ...args) => log.warn(message, ...args),
|
logWarn: (message, ...args) => log.warn(message, ...args),
|
||||||
logError: (message, ...args) => log.error(message, ...args),
|
logError: (message, ...args) => log.error(message, ...args),
|
||||||
@@ -733,14 +731,6 @@ export class AnkiIntegration {
|
|||||||
return this.knownWordCache.isKnownWord(text, reading, options);
|
return this.knownWordCache.isKnownWord(text, reading, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
getKnownWordTier(
|
|
||||||
text: string,
|
|
||||||
reading?: string,
|
|
||||||
options?: { allowReadingOnlyMatch?: boolean },
|
|
||||||
): KnownWordMaturityTier | null {
|
|
||||||
return this.knownWordCache.getKnownWordTier(text, reading, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
getKnownWordMatchMode(): NPlusOneMatchMode {
|
getKnownWordMatchMode(): NPlusOneMatchMode {
|
||||||
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
|
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -543,41 +543,3 @@ test('proxy detects self-referential loop configuration', () => {
|
|||||||
|
|
||||||
assert.equal(result, true);
|
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;
|
logInfo: (message: string, ...args: unknown[]) => void;
|
||||||
logWarn: (message: string, ...args: unknown[]) => void;
|
logWarn: (message: string, ...args: unknown[]) => void;
|
||||||
logError: (message: string, ...args: unknown[]) => void;
|
logError: (message: string, ...args: unknown[]) => void;
|
||||||
notifyUnavailable?: (message: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnkiConnectProxyServer {
|
export class AnkiConnectProxyServer {
|
||||||
@@ -79,23 +78,7 @@ export class AnkiConnectProxyServer {
|
|||||||
void this.handleRequest(req, res, options.upstreamUrl);
|
void this.handleRequest(req, res, options.upstreamUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = this.server;
|
|
||||||
this.server.on('error', (error) => {
|
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.rejectReady?.(error as Error);
|
||||||
this.resolveReady = null;
|
this.resolveReady = null;
|
||||||
this.rejectReady = null;
|
this.rejectReady = null;
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import {
|
|
||||||
KnownWordCacheState,
|
|
||||||
knownWordsFromState,
|
|
||||||
parseKnownWordCacheState,
|
|
||||||
} from './known-word-cache-format';
|
|
||||||
|
|
||||||
function parseOrThrow(value: unknown): KnownWordCacheState {
|
|
||||||
const parsed = parseKnownWordCacheState(value);
|
|
||||||
assert.ok(parsed, 'expected the payload to parse');
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE = { refreshedAtMs: 1, scope: 'deck:test' };
|
|
||||||
|
|
||||||
test('known-word cache format reads words from every version the union covers', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
knownWordsFromState(parseOrThrow({ ...BASE, version: 1, words: ['する'] })),
|
|
||||||
new Set(['する']),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
knownWordsFromState(
|
|
||||||
parseOrThrow({ ...BASE, version: 2, words: ['する'], notes: { '1': ['する'] } }),
|
|
||||||
),
|
|
||||||
new Set(['する']),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
knownWordsFromState(
|
|
||||||
parseOrThrow({
|
|
||||||
...BASE,
|
|
||||||
version: 3,
|
|
||||||
notes: { '1': [{ word: 'する', reading: 'する' }], '2': [{ word: '猫', reading: null }] },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
new Set(['する', '猫']),
|
|
||||||
);
|
|
||||||
|
|
||||||
// v4 only adds `tiers`; the words a reader sees must not change.
|
|
||||||
assert.deepEqual(
|
|
||||||
knownWordsFromState(
|
|
||||||
parseOrThrow({
|
|
||||||
...BASE,
|
|
||||||
version: 4,
|
|
||||||
notes: { '1': [{ word: 'する', reading: 'する' }], '2': [{ word: '猫', reading: null }] },
|
|
||||||
tiers: { '1': 'mature', '2': 'young' },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
new Set(['する', '猫']),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('known-word cache format rejects payloads that are not a known cache state', () => {
|
|
||||||
const notes = { '1': [{ word: 'する', reading: null }] };
|
|
||||||
|
|
||||||
assert.equal(parseKnownWordCacheState(null), null);
|
|
||||||
assert.equal(parseKnownWordCacheState('{}'), null);
|
|
||||||
|
|
||||||
// An unknown version must not be read as an empty cache.
|
|
||||||
assert.equal(parseKnownWordCacheState({ ...BASE, version: 99, notes }), null);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
parseKnownWordCacheState({ version: 4, scope: 'deck:test', notes, tiers: {} }),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
assert.equal(parseKnownWordCacheState({ version: 4, refreshedAtMs: 1, notes, tiers: {} }), null);
|
|
||||||
|
|
||||||
// v4 without its tiers map is a v3 payload mislabelled as v4.
|
|
||||||
assert.equal(parseKnownWordCacheState({ ...BASE, version: 4, notes }), null);
|
|
||||||
|
|
||||||
// v3 carries entry objects, not the bare strings v2 used.
|
|
||||||
assert.equal(parseKnownWordCacheState({ ...BASE, version: 3, notes: { '1': ['する'] } }), null);
|
|
||||||
});
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
// On-disk shape of the known-word cache, plus the only parser for it.
|
|
||||||
//
|
|
||||||
// Two processes read this file: the cache manager (which rebuilds its indexes
|
|
||||||
// from it) and the stats server (which counts known words). They used to carry
|
|
||||||
// separate hand-written parsers, so bumping the format to v4 for maturity tiers
|
|
||||||
// left the stats server silently reporting zero known words. Everything that
|
|
||||||
// touches the format now goes through here, and the version dispatch below ends
|
|
||||||
// in assertNever so adding a V5 to the union fails the build at every consumer
|
|
||||||
// instead of degrading to an empty result at runtime.
|
|
||||||
|
|
||||||
import type { KnownWordMaturityTier } from '../types/subtitle';
|
|
||||||
import type { KnownWordEntry } from './known-word-entries';
|
|
||||||
|
|
||||||
export interface KnownWordCacheStateV1 {
|
|
||||||
readonly version: 1;
|
|
||||||
readonly refreshedAtMs: number;
|
|
||||||
readonly scope: string;
|
|
||||||
readonly words: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KnownWordCacheStateV2 {
|
|
||||||
readonly version: 2;
|
|
||||||
readonly refreshedAtMs: number;
|
|
||||||
readonly scope: string;
|
|
||||||
readonly words: string[];
|
|
||||||
readonly notes: Record<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KnownWordCacheStateV3 {
|
|
||||||
readonly version: 3;
|
|
||||||
readonly refreshedAtMs: number;
|
|
||||||
readonly scope: string;
|
|
||||||
readonly notes: Record<string, KnownWordEntry[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KnownWordCacheStateV4 {
|
|
||||||
readonly version: 4;
|
|
||||||
readonly refreshedAtMs: number;
|
|
||||||
readonly scope: string;
|
|
||||||
readonly notes: Record<string, KnownWordEntry[]>;
|
|
||||||
readonly tiers: Record<string, KnownWordMaturityTier>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KnownWordCacheState =
|
|
||||||
| KnownWordCacheStateV1
|
|
||||||
| KnownWordCacheStateV2
|
|
||||||
| KnownWordCacheStateV3
|
|
||||||
| KnownWordCacheStateV4;
|
|
||||||
|
|
||||||
// Version written by persistKnownWordCacheState. Readers accept every version
|
|
||||||
// in the union above; only the writer pins one.
|
|
||||||
export type CurrentKnownWordCacheState = KnownWordCacheStateV4;
|
|
||||||
|
|
||||||
// Exported so every consumer that switches on `version` can close its dispatch
|
|
||||||
// the same way: a new member of the union becomes a type error at each call
|
|
||||||
// site rather than a case that silently falls through.
|
|
||||||
export function assertNever(value: never): never {
|
|
||||||
throw new Error(`Unhandled known-word cache state: ${JSON.stringify(value)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEntryRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isKnownWordEntry(entry: unknown): boolean {
|
|
||||||
if (!isEntryRecord(entry)) return false;
|
|
||||||
const candidate = entry as Partial<KnownWordEntry>;
|
|
||||||
return (
|
|
||||||
typeof candidate.word === 'string' &&
|
|
||||||
(candidate.reading === null || typeof candidate.reading === 'string')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the narrowed state, or null when the payload is not a cache state we
|
|
||||||
// recognize. Per-entry values that are merely unusable (an unknown maturity
|
|
||||||
// tier, a non-numeric note id) are dropped by callers at load time rather than
|
|
||||||
// rejecting the whole file.
|
|
||||||
export function parseKnownWordCacheState(value: unknown): KnownWordCacheState | null {
|
|
||||||
if (!isEntryRecord(value)) return null;
|
|
||||||
const candidate = value;
|
|
||||||
if (
|
|
||||||
candidate.version !== 1 &&
|
|
||||||
candidate.version !== 2 &&
|
|
||||||
candidate.version !== 3 &&
|
|
||||||
candidate.version !== 4
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof candidate.refreshedAtMs !== 'number') return null;
|
|
||||||
if (typeof candidate.scope !== 'string') return null;
|
|
||||||
|
|
||||||
if (candidate.version === 1 || candidate.version === 2) {
|
|
||||||
if (!Array.isArray(candidate.words)) return null;
|
|
||||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate.version === 4) {
|
|
||||||
// Per-tier values are sanitized entry-by-entry at load time.
|
|
||||||
if (!isEntryRecord(candidate.tiers)) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate.version === 2 || candidate.version === 3 || candidate.version === 4) {
|
|
||||||
if (!isEntryRecord(candidate.notes)) return null;
|
|
||||||
const isValidNoteEntry =
|
|
||||||
candidate.version === 2
|
|
||||||
? (entry: unknown): boolean => typeof entry === 'string'
|
|
||||||
: isKnownWordEntry;
|
|
||||||
if (
|
|
||||||
!Object.values(candidate.notes).every(
|
|
||||||
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidate as unknown as KnownWordCacheState;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every word the cache considers known, flattened across notes. Consumers that
|
|
||||||
// only need membership (the stats server) use this instead of walking the
|
|
||||||
// version-specific layout themselves.
|
|
||||||
export function knownWordsFromState(state: KnownWordCacheState): Set<string> {
|
|
||||||
switch (state.version) {
|
|
||||||
case 1:
|
|
||||||
case 2:
|
|
||||||
return new Set(state.words);
|
|
||||||
case 3:
|
|
||||||
case 4: {
|
|
||||||
const words = new Set<string>();
|
|
||||||
for (const entries of Object.values(state.notes)) {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.word) words.add(entry.word);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return words;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return assertNever(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
|
||||||
import { setLogLevel } from '../logger';
|
|
||||||
import { KnownWordCacheManager, getKnownWordCacheLifecycleConfig } from './known-word-cache';
|
|
||||||
|
|
||||||
interface HarnessNoteInfo {
|
|
||||||
noteId: number;
|
|
||||||
fields: Record<string, { value: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMaturityHarness(config: AnkiConnectConfig): {
|
|
||||||
manager: KnownWordCacheManager;
|
|
||||||
calls: { findNotes: number; notesInfo: number; queries: string[] };
|
|
||||||
statePath: string;
|
|
||||||
clientState: {
|
|
||||||
findNotesResult: number[];
|
|
||||||
notesInfoResult: HarnessNoteInfo[];
|
|
||||||
findNotesByQuery: Map<string, number[]>;
|
|
||||||
failedQueries: Set<string>;
|
|
||||||
};
|
|
||||||
createSiblingManager: () => KnownWordCacheManager;
|
|
||||||
cleanup: () => void;
|
|
||||||
} {
|
|
||||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-maturity-'));
|
|
||||||
const statePath = path.join(stateDir, 'known-words-cache.json');
|
|
||||||
const calls = { findNotes: 0, notesInfo: 0, queries: [] as string[] };
|
|
||||||
const clientState = {
|
|
||||||
findNotesResult: [] as number[],
|
|
||||||
notesInfoResult: [] as HarnessNoteInfo[],
|
|
||||||
findNotesByQuery: new Map<string, number[]>(),
|
|
||||||
failedQueries: new Set<string>(),
|
|
||||||
};
|
|
||||||
const deps = {
|
|
||||||
client: {
|
|
||||||
findNotes: async (query: string) => {
|
|
||||||
calls.findNotes += 1;
|
|
||||||
calls.queries.push(query);
|
|
||||||
if (clientState.failedQueries.has(query)) {
|
|
||||||
throw new Error(`Anki unavailable for query: ${query}`);
|
|
||||||
}
|
|
||||||
if (clientState.findNotesByQuery.has(query)) {
|
|
||||||
return clientState.findNotesByQuery.get(query) ?? [];
|
|
||||||
}
|
|
||||||
return clientState.findNotesResult;
|
|
||||||
},
|
|
||||||
notesInfo: async (noteIds: number[]) => {
|
|
||||||
calls.notesInfo += 1;
|
|
||||||
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getConfig: () => config,
|
|
||||||
knownWordCacheStatePath: statePath,
|
|
||||||
showStatusNotification: () => undefined,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
manager: new KnownWordCacheManager(deps),
|
|
||||||
calls,
|
|
||||||
statePath,
|
|
||||||
clientState,
|
|
||||||
createSiblingManager: () => new KnownWordCacheManager(deps),
|
|
||||||
cleanup: () => {
|
|
||||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The four queries a maturity refresh issues, in one place so a query-string
|
|
||||||
// change lands in a single spot.
|
|
||||||
function setTierQueries(
|
|
||||||
clientState: { findNotesByQuery: Map<string, number[]> },
|
|
||||||
tiers: { all: number[]; mature: number[]; young: number[]; learning: number[] },
|
|
||||||
): void {
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining"', tiers.all);
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', tiers.mature);
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', tiers.young);
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining" is:learn', tiers.learning);
|
|
||||||
}
|
|
||||||
|
|
||||||
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
|
|
||||||
return {
|
|
||||||
deck: 'Mining',
|
|
||||||
fields: { word: 'Word' },
|
|
||||||
knownWords: {
|
|
||||||
highlightEnabled: true,
|
|
||||||
maturityEnabled: true,
|
|
||||||
refreshMinutes: 60,
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('lifecycle config key is unchanged when maturity is disabled', () => {
|
|
||||||
const disabled: AnkiConnectConfig = {
|
|
||||||
knownWords: { highlightEnabled: true, refreshMinutes: 60 },
|
|
||||||
};
|
|
||||||
// Upgrading users keep their persisted cache: the key must not gain fields
|
|
||||||
// while maturity is off.
|
|
||||||
assert.equal(
|
|
||||||
getKnownWordCacheLifecycleConfig(disabled),
|
|
||||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
|
||||||
);
|
|
||||||
|
|
||||||
const enabled: AnkiConnectConfig = {
|
|
||||||
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
|
|
||||||
};
|
|
||||||
assert.equal(
|
|
||||||
getKnownWordCacheLifecycleConfig(enabled),
|
|
||||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21,"maturityRules":2}',
|
|
||||||
);
|
|
||||||
|
|
||||||
const customThreshold: AnkiConnectConfig = {
|
|
||||||
knownWords: {
|
|
||||||
highlightEnabled: true,
|
|
||||||
maturityEnabled: true,
|
|
||||||
matureThresholdDays: 30,
|
|
||||||
refreshMinutes: 60,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
assert.equal(
|
|
||||||
getKnownWordCacheLifecycleConfig(customThreshold),
|
|
||||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30,"maturityRules":2}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a cache built under the old tier rules is invalidated', () => {
|
|
||||||
const config: AnkiConnectConfig = {
|
|
||||||
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
|
|
||||||
};
|
|
||||||
// v1 rules put lapsed cards in young because the interval queries did not
|
|
||||||
// exclude is:learn; those persisted tiers must not be served under v2.
|
|
||||||
assert.notEqual(
|
|
||||||
getKnownWordCacheLifecycleConfig(config),
|
|
||||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
|
|
||||||
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
|
|
||||||
try {
|
|
||||||
setTierQueries(clientState, { all: [1, 2, 3, 4], mature: [1], young: [2], learning: [3] });
|
|
||||||
clientState.notesInfoResult = [
|
|
||||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
|
||||||
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
|
||||||
{ noteId: 3, fields: { Word: { value: '鳥' } } },
|
|
||||||
{ noteId: 4, fields: { Word: { value: '魚' } } },
|
|
||||||
];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
assert.equal(calls.findNotes, 4);
|
|
||||||
assert.equal(manager.getKnownWordTier('猫'), 'mature');
|
|
||||||
assert.equal(manager.getKnownWordTier('犬'), 'young');
|
|
||||||
assert.equal(manager.getKnownWordTier('鳥'), 'learning');
|
|
||||||
assert.equal(manager.getKnownWordTier('魚'), 'new');
|
|
||||||
assert.equal(manager.getKnownWordTier('馬'), null);
|
|
||||||
// Boolean matching still works alongside tiers.
|
|
||||||
assert.equal(manager.isKnownWord('猫'), true);
|
|
||||||
assert.equal(manager.isKnownWord('魚'), true);
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a note with cards in several tiers counts as its most mature card', async () => {
|
|
||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
|
|
||||||
try {
|
|
||||||
setTierQueries(clientState, { all: [1], mature: [1], young: [1], learning: [1] });
|
|
||||||
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
assert.equal(manager.getKnownWordTier('猫'), 'mature');
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a word matched by several notes takes the most mature note tier', async () => {
|
|
||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
|
|
||||||
try {
|
|
||||||
setTierQueries(clientState, { all: [1, 2], mature: [], young: [2], learning: [1] });
|
|
||||||
clientState.notesInfoResult = [
|
|
||||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
|
||||||
{ noteId: 2, fields: { Word: { value: '猫' } } },
|
|
||||||
];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
assert.equal(manager.getKnownWordTier('猫'), 'young');
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tiers are reading-aware for words with several readings', async () => {
|
|
||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
|
|
||||||
try {
|
|
||||||
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
|
|
||||||
clientState.notesInfoResult = [
|
|
||||||
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
|
||||||
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
|
||||||
];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
assert.equal(manager.getKnownWordTier('床', 'とこ'), 'mature');
|
|
||||||
assert.equal(manager.getKnownWordTier('床', 'ゆか'), 'learning');
|
|
||||||
// No reading given: fail-open across readings, most mature wins.
|
|
||||||
assert.equal(manager.getKnownWordTier('床'), 'mature');
|
|
||||||
// Unknown reading for a reading-locked word: no match, no tier.
|
|
||||||
assert.equal(manager.getKnownWordTier('床', 'しょう'), null);
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reading-only fallback resolves tiers unless opted out', async () => {
|
|
||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
|
|
||||||
try {
|
|
||||||
setTierQueries(clientState, { all: [1], mature: [1], young: [], learning: [] });
|
|
||||||
clientState.notesInfoResult = [
|
|
||||||
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
|
|
||||||
];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
assert.equal(manager.getKnownWordTier('けいこく'), 'mature');
|
|
||||||
assert.equal(
|
|
||||||
manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getKnownWordTier returns null and skips tier queries when maturity is disabled', async () => {
|
|
||||||
const config = maturityConfig();
|
|
||||||
config.knownWords = { ...config.knownWords, maturityEnabled: false };
|
|
||||||
const { manager, calls, clientState, cleanup } = createMaturityHarness(config);
|
|
||||||
|
|
||||||
try {
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
|
||||||
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
assert.equal(calls.findNotes, 1);
|
|
||||||
assert.equal(manager.isKnownWord('猫'), true);
|
|
||||||
assert.equal(manager.getKnownWordTier('猫'), null);
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refresh preserves known-word cache when maturity lookup fails', async () => {
|
|
||||||
const { manager, statePath, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
const originalInfo = console.info;
|
|
||||||
const infoLogs: string[] = [];
|
|
||||||
setLogLevel('info');
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.info = (...args: unknown[]) => {
|
|
||||||
infoLogs.push(args.map((value) => String(value)).join(' '));
|
|
||||||
};
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
|
||||||
clientState.failedQueries.add('deck:"Mining" prop:ivl>=21 -is:learn');
|
|
||||||
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
assert.equal(manager.isKnownWord('猫'), true);
|
|
||||||
assert.equal(manager.getKnownWordTier('猫'), null);
|
|
||||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
|
||||||
version: number;
|
|
||||||
tiers: Record<string, string>;
|
|
||||||
};
|
|
||||||
assert.equal(persisted.version, 4);
|
|
||||||
assert.deepEqual(persisted.tiers, {});
|
|
||||||
assert.match(infoLogs.join('\n'), /maturityTiers=fetch-failed/);
|
|
||||||
} finally {
|
|
||||||
console.info = originalInfo;
|
|
||||||
setLogLevel(undefined);
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tiers persist to v4 state and reload without refetching', async () => {
|
|
||||||
const { manager, calls, statePath, clientState, createSiblingManager, cleanup } =
|
|
||||||
createMaturityHarness(maturityConfig());
|
|
||||||
const originalDateNow = Date.now;
|
|
||||||
|
|
||||||
try {
|
|
||||||
Date.now = () => 120_000;
|
|
||||||
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
|
|
||||||
clientState.notesInfoResult = [
|
|
||||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
|
||||||
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
|
||||||
];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
|
||||||
version: number;
|
|
||||||
tiers: Record<string, string>;
|
|
||||||
};
|
|
||||||
assert.equal(persisted.version, 4);
|
|
||||||
assert.deepEqual(persisted.tiers, { '1': 'mature', '2': 'learning' });
|
|
||||||
|
|
||||||
const callsBeforeReload = calls.findNotes;
|
|
||||||
const reloaded = createSiblingManager();
|
|
||||||
reloaded.startLifecycle();
|
|
||||||
try {
|
|
||||||
assert.equal(reloaded.getKnownWordTier('猫'), 'mature');
|
|
||||||
assert.equal(reloaded.getKnownWordTier('犬'), 'learning');
|
|
||||||
assert.equal(calls.findNotes, callsBeforeReload);
|
|
||||||
} finally {
|
|
||||||
reloaded.stopLifecycle();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
Date.now = originalDateNow;
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('appendFromNoteInfo marks freshly mined notes as new tier', async () => {
|
|
||||||
const { manager, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
|
|
||||||
try {
|
|
||||||
manager.appendFromNoteInfo({
|
|
||||||
noteId: 7,
|
|
||||||
fields: { Word: { value: '猫' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(manager.isKnownWord('猫'), true);
|
|
||||||
assert.equal(manager.getKnownWordTier('猫'), 'new');
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('appendFromNoteInfo preserves an existing maturity tier', async () => {
|
|
||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
|
|
||||||
try {
|
|
||||||
setTierQueries(clientState, { all: [7, 8], mature: [7], young: [], learning: [8] });
|
|
||||||
clientState.notesInfoResult = [
|
|
||||||
{ noteId: 7, fields: { Word: { value: '猫' } } },
|
|
||||||
{ noteId: 8, fields: { Word: { value: '犬' } } },
|
|
||||||
];
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
manager.appendFromNoteInfo({
|
|
||||||
noteId: 7,
|
|
||||||
fields: { Word: { value: '子猫' } },
|
|
||||||
});
|
|
||||||
manager.appendFromNoteInfo({
|
|
||||||
noteId: 8,
|
|
||||||
fields: { Word: { value: '子犬' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(manager.getKnownWordTier('子猫'), 'mature');
|
|
||||||
assert.equal(manager.getKnownWordTier('子犬'), 'learning');
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getKnownWordMatchNoteIds reports the notes behind a tier', async () => {
|
|
||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
|
|
||||||
try {
|
|
||||||
setTierQueries(clientState, { all: [1, 2, 3], mature: [1], young: [2], learning: [3] });
|
|
||||||
clientState.notesInfoResult = [
|
|
||||||
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
|
||||||
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
|
||||||
{ noteId: 3, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
|
|
||||||
];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
// Same matching rules as getKnownWordTier, so an audit can re-derive the
|
|
||||||
// rendered tier from the exact notes that produced it.
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'とこ')], [1]);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'ゆか')], [2]);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床')].sort(), [1, 2]);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'しょう')], []);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('けいこく')], [3]);
|
|
||||||
assert.deepEqual(
|
|
||||||
[
|
|
||||||
...manager.getKnownWordMatchNoteIds('けいこく', undefined, {
|
|
||||||
allowReadingOnlyMatch: false,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('馬')], []);
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -314,7 +314,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
|||||||
version: number;
|
version: number;
|
||||||
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
|
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||||
};
|
};
|
||||||
assert.equal(persisted.version, 4);
|
assert.equal(persisted.version, 3);
|
||||||
assert.deepEqual(persisted.notes, {
|
assert.deepEqual(persisted.notes, {
|
||||||
'1': [{ word: '鳥', reading: null }],
|
'1': [{ word: '鳥', reading: null }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,22 +4,7 @@ import path from 'path';
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getConfiguredWordFieldName } from '../anki-field-config';
|
import { getConfiguredWordFieldName } from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import { AnkiConnectConfig } from '../types/anki';
|
||||||
import type { KnownWordMaturityTier } from '../types/subtitle';
|
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import {
|
|
||||||
KNOWN_WORD_MATURITY_RULES_VERSION,
|
|
||||||
classifyKnownWordNoteTier,
|
|
||||||
fetchKnownWordMaturityTierSets,
|
|
||||||
getKnownWordMaturityEnabled,
|
|
||||||
getMatureIntervalThresholdDays,
|
|
||||||
maxKnownWordMaturityTier,
|
|
||||||
sanitizeKnownWordMaturityTier,
|
|
||||||
} from './known-word-maturity';
|
|
||||||
import {
|
|
||||||
CurrentKnownWordCacheState,
|
|
||||||
assertNever,
|
|
||||||
parseKnownWordCacheState,
|
|
||||||
} from './known-word-cache-format';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
||||||
KnownWordEntry,
|
KnownWordEntry,
|
||||||
@@ -77,21 +62,11 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
||||||
const payload: Record<string, unknown> = {
|
return JSON.stringify({
|
||||||
refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config),
|
refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config),
|
||||||
scope: getKnownWordCacheScopeForConfig(config),
|
scope: getKnownWordCacheScopeForConfig(config),
|
||||||
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
|
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
|
||||||
};
|
});
|
||||||
// The maturity fields are only added while enabled so persisted caches from
|
|
||||||
// before the feature existed (or with it off) keep their identity.
|
|
||||||
// maturityRules is the classification-rule version: bump it whenever the tier
|
|
||||||
// queries change meaning so existing caches refetch instead of serving tiers
|
|
||||||
// computed under the old rules.
|
|
||||||
if (getKnownWordMaturityEnabled(config)) {
|
|
||||||
payload.maturity = getMatureIntervalThresholdDays(config);
|
|
||||||
payload.maturityRules = KNOWN_WORD_MATURITY_RULES_VERSION;
|
|
||||||
}
|
|
||||||
return JSON.stringify(payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KnownWordCacheNoteInfo {
|
export interface KnownWordCacheNoteInfo {
|
||||||
@@ -99,6 +74,30 @@ export interface KnownWordCacheNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KnownWordCacheStateV1 {
|
||||||
|
readonly version: 1;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly words: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnownWordCacheStateV2 {
|
||||||
|
readonly version: 2;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly words: string[];
|
||||||
|
readonly notes: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnownWordCacheStateV3 {
|
||||||
|
readonly version: 3;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly notes: Record<string, KnownWordEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
|
||||||
|
|
||||||
const NO_READING_KEY = '';
|
const NO_READING_KEY = '';
|
||||||
|
|
||||||
interface KnownWordCacheClient {
|
interface KnownWordCacheClient {
|
||||||
@@ -126,13 +125,12 @@ type KnownWordQueryScope = {
|
|||||||
export class KnownWordCacheManager {
|
export class KnownWordCacheManager {
|
||||||
private knownWordsLastRefreshedAtMs = 0;
|
private knownWordsLastRefreshedAtMs = 0;
|
||||||
private knownWordsStateKey = '';
|
private knownWordsStateKey = '';
|
||||||
// word → (hiragana reading | NO_READING_KEY → note ids). NO_READING_KEY
|
// word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
|
||||||
// entries fail open: the word matches regardless of the token's reading.
|
// entries fail open: the word matches regardless of the token's reading.
|
||||||
private wordReadingNoteIds = new Map<string, Map<string, Set<number>>>();
|
private wordReadingCounts = new Map<string, Map<string, number>>();
|
||||||
// hiragana reading → note ids, so kana tokens still match by reading alone.
|
// hiragana reading → note count, so kana tokens still match by reading alone.
|
||||||
private readingNoteIds = new Map<string, Set<number>>();
|
private readingCounts = new Map<string, number>();
|
||||||
private noteEntriesById = new Map<number, KnownWordEntry[]>();
|
private noteEntriesById = new Map<number, KnownWordEntry[]>();
|
||||||
private noteTierById = new Map<number, KnownWordMaturityTier>();
|
|
||||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private isRefreshingKnownWords = false;
|
private isRefreshingKnownWords = false;
|
||||||
@@ -158,7 +156,7 @@ export class KnownWordCacheManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownReadings = this.wordReadingNoteIds.get(normalized);
|
const knownReadings = this.wordReadingCounts.get(normalized);
|
||||||
if (knownReadings && knownReadings.size > 0) {
|
if (knownReadings && knownReadings.size > 0) {
|
||||||
const normalizedReading =
|
const normalizedReading =
|
||||||
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||||
@@ -170,7 +168,7 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Callers that look up a kanji token's reading (not subtitle text) must
|
// Callers that look up a kanji token's reading (not subtitle text) must
|
||||||
// opt out of the reading-only fallback: readingNoteIds holds readings of
|
// opt out of the reading-only fallback: readingCounts holds readings of
|
||||||
// every note including kanji words, so 渓谷's けいこく would match a
|
// every note including kanji words, so 渓谷's けいこく would match a
|
||||||
// mined 警告/けいこく.
|
// mined 警告/けいこく.
|
||||||
if (options?.allowReadingOnlyMatch === false) {
|
if (options?.allowReadingOnlyMatch === false) {
|
||||||
@@ -184,86 +182,7 @@ export class KnownWordCacheManager {
|
|||||||
if ([...hiragana].length === 1) {
|
if ([...hiragana].length === 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.readingNoteIds.has(hiragana);
|
return this.readingCounts.has(hiragana);
|
||||||
}
|
|
||||||
|
|
||||||
// Maturity tier for a matching known word, following the exact matching
|
|
||||||
// rules of isKnownWord. A match with no tier data (tier fetch failed or
|
|
||||||
// pre-v4 cache) returns null so rendering falls back to the single
|
|
||||||
// known-word color.
|
|
||||||
getKnownWordTier(
|
|
||||||
text: string,
|
|
||||||
reading?: string,
|
|
||||||
options?: { allowReadingOnlyMatch?: boolean },
|
|
||||||
): KnownWordMaturityTier | null {
|
|
||||||
if (!getKnownWordMaturityEnabled(this.deps.getConfig())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.maxTierForNotes(null, this.getKnownWordMatchNoteIds(text, reading, options));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note ids a known-word lookup matches, using the same matching rules as
|
|
||||||
// getKnownWordTier. Exposed for diagnostics (see
|
|
||||||
// scripts/verify-known-word-highlights.ts), which audits a rendered tier
|
|
||||||
// against the live card data of the notes that produced it.
|
|
||||||
getKnownWordMatchNoteIds(
|
|
||||||
text: string,
|
|
||||||
reading?: string,
|
|
||||||
options?: { allowReadingOnlyMatch?: boolean },
|
|
||||||
): Set<number> {
|
|
||||||
const matches = new Set<number>();
|
|
||||||
const normalized = this.normalizeKnownWordForLookup(text);
|
|
||||||
if (normalized.length === 0) {
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownReadings = this.wordReadingNoteIds.get(normalized);
|
|
||||||
if (knownReadings && knownReadings.size > 0) {
|
|
||||||
const normalizedReading =
|
|
||||||
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
|
||||||
if (normalizedReading.length === 0) {
|
|
||||||
for (const noteIds of knownReadings.values()) {
|
|
||||||
for (const noteId of noteIds) {
|
|
||||||
matches.add(noteId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
for (const key of [NO_READING_KEY, normalizedReading]) {
|
|
||||||
for (const noteId of knownReadings.get(key) ?? []) {
|
|
||||||
matches.add(noteId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.allowReadingOnlyMatch === false) {
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hiragana = convertKatakanaToHiragana(normalized);
|
|
||||||
if ([...hiragana].length === 1) {
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
for (const noteId of this.readingNoteIds.get(hiragana) ?? []) {
|
|
||||||
matches.add(noteId);
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
private maxTierForNotes(
|
|
||||||
current: KnownWordMaturityTier | null,
|
|
||||||
noteIds: ReadonlySet<number>,
|
|
||||||
): KnownWordMaturityTier | null {
|
|
||||||
let tier = current;
|
|
||||||
for (const noteId of noteIds) {
|
|
||||||
tier = maxKnownWordMaturityTier(tier, this.noteTierById.get(noteId) ?? null);
|
|
||||||
if (tier === 'mature') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tier;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(force = false): Promise<void> {
|
refresh(force = false): Promise<void> {
|
||||||
@@ -310,7 +229,7 @@ export class KnownWordCacheManager {
|
|||||||
let didMutateCache = false;
|
let didMutateCache = false;
|
||||||
const currentStateKey = this.getKnownWordCacheStateKey();
|
const currentStateKey = this.getKnownWordCacheStateKey();
|
||||||
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
||||||
didMutateCache = this.wordReadingNoteIds.size > 0 || this.noteEntriesById.size > 0;
|
didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
|
||||||
this.clearKnownWordCacheState();
|
this.clearKnownWordCacheState();
|
||||||
}
|
}
|
||||||
if (!this.knownWordsStateKey) {
|
if (!this.knownWordsStateKey) {
|
||||||
@@ -328,15 +247,6 @@ export class KnownWordCacheManager {
|
|||||||
return didMutateCache;
|
return didMutateCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A just-mined card has never been reviewed.
|
|
||||||
if (
|
|
||||||
this.isMaturityTrackingEnabled() &&
|
|
||||||
this.noteEntriesById.has(noteInfo.noteId) &&
|
|
||||||
!this.noteTierById.has(noteInfo.noteId)
|
|
||||||
) {
|
|
||||||
this.noteTierById.set(noteInfo.noteId, 'new');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||||
}
|
}
|
||||||
@@ -380,21 +290,6 @@ export class KnownWordCacheManager {
|
|||||||
this.isRefreshingKnownWords = true;
|
this.isRefreshingKnownWords = true;
|
||||||
try {
|
try {
|
||||||
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
||||||
const maturityTrackingEnabled = this.isMaturityTrackingEnabled();
|
|
||||||
let maturityFetchFailed = false;
|
|
||||||
let tierSets = null;
|
|
||||||
if (maturityTrackingEnabled) {
|
|
||||||
try {
|
|
||||||
tierSets = await fetchKnownWordMaturityTierSets(
|
|
||||||
(query, options) => this.deps.client.findNotes(query, options),
|
|
||||||
this.getKnownWordQueryScopes().map((scope) => scope.query),
|
|
||||||
getMatureIntervalThresholdDays(this.deps.getConfig()),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
maturityFetchFailed = true;
|
|
||||||
log.warn('Failed to fetch known-word maturity tiers:', (error as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
||||||
|
|
||||||
if (this.noteEntriesById.size === 0) {
|
if (this.noteEntriesById.size === 0) {
|
||||||
@@ -421,25 +316,13 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.noteTierById = new Map();
|
|
||||||
if (tierSets) {
|
|
||||||
for (const noteId of currentNoteIds) {
|
|
||||||
this.noteTierById.set(noteId, classifyKnownWordNoteTier(noteId, tierSets));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||||
this.knownWordsStateKey = frozenStateKey;
|
this.knownWordsStateKey = frozenStateKey;
|
||||||
this.persistKnownWordCacheState();
|
this.persistKnownWordCacheState();
|
||||||
log.info(
|
log.info(
|
||||||
'Known-word cache refreshed',
|
'Known-word cache refreshed',
|
||||||
`noteCount=${currentNoteIds.length}`,
|
`noteCount=${currentNoteIds.length}`,
|
||||||
`wordCount=${this.wordReadingNoteIds.size}`,
|
`wordCount=${this.wordReadingCounts.size}`,
|
||||||
tierSets
|
|
||||||
? `maturityTiers=${this.noteTierById.size}`
|
|
||||||
: maturityFetchFailed
|
|
||||||
? 'maturityTiers=fetch-failed'
|
|
||||||
: 'maturityTiers=off',
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
||||||
@@ -454,10 +337,6 @@ export class KnownWordCacheManager {
|
|||||||
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isMaturityTrackingEnabled(): boolean {
|
|
||||||
return getKnownWordMaturityEnabled(this.deps.getConfig());
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldAddMinedWordsImmediately(): boolean {
|
private shouldAddMinedWordsImmediately(): boolean {
|
||||||
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
|
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
|
||||||
}
|
}
|
||||||
@@ -714,13 +593,12 @@ export class KnownWordCacheManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.removeEntriesFromIndexes(noteId, previousEntries);
|
this.removeEntriesFromCounts(previousEntries);
|
||||||
if (normalizedEntries.length > 0) {
|
if (normalizedEntries.length > 0) {
|
||||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
this.addEntriesToIndexes(noteId, normalizedEntries);
|
this.addEntriesToCounts(normalizedEntries);
|
||||||
} else {
|
} else {
|
||||||
this.noteEntriesById.delete(noteId);
|
this.noteEntriesById.delete(noteId);
|
||||||
this.noteTierById.delete(noteId);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -731,68 +609,54 @@ export class KnownWordCacheManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.noteEntriesById.delete(noteId);
|
this.noteEntriesById.delete(noteId);
|
||||||
this.noteTierById.delete(noteId);
|
this.removeEntriesFromCounts(previousEntries);
|
||||||
this.removeEntriesFromIndexes(noteId, previousEntries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private addEntriesToIndexes(noteId: number, entries: KnownWordEntry[]): void {
|
private addEntriesToCounts(entries: KnownWordEntry[]): void {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||||
let readings = this.wordReadingNoteIds.get(entry.word);
|
let readings = this.wordReadingCounts.get(entry.word);
|
||||||
if (!readings) {
|
if (!readings) {
|
||||||
readings = new Map();
|
readings = new Map();
|
||||||
this.wordReadingNoteIds.set(entry.word, readings);
|
this.wordReadingCounts.set(entry.word, readings);
|
||||||
}
|
}
|
||||||
let noteIds = readings.get(readingKey);
|
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
|
||||||
if (!noteIds) {
|
|
||||||
noteIds = new Set();
|
|
||||||
readings.set(readingKey, noteIds);
|
|
||||||
}
|
|
||||||
noteIds.add(noteId);
|
|
||||||
if (entry.reading) {
|
if (entry.reading) {
|
||||||
let readingNotes = this.readingNoteIds.get(entry.reading);
|
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
|
||||||
if (!readingNotes) {
|
|
||||||
readingNotes = new Set();
|
|
||||||
this.readingNoteIds.set(entry.reading, readingNotes);
|
|
||||||
}
|
|
||||||
readingNotes.add(noteId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeEntriesFromIndexes(noteId: number, entries: KnownWordEntry[]): void {
|
private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||||
const readings = this.wordReadingNoteIds.get(entry.word);
|
const readings = this.wordReadingCounts.get(entry.word);
|
||||||
if (readings) {
|
if (readings) {
|
||||||
const noteIds = readings.get(readingKey);
|
const nextCount = (readings.get(readingKey) ?? 0) - 1;
|
||||||
if (noteIds) {
|
if (nextCount > 0) {
|
||||||
noteIds.delete(noteId);
|
readings.set(readingKey, nextCount);
|
||||||
if (noteIds.size === 0) {
|
} else {
|
||||||
readings.delete(readingKey);
|
readings.delete(readingKey);
|
||||||
if (readings.size === 0) {
|
if (readings.size === 0) {
|
||||||
this.wordReadingNoteIds.delete(entry.word);
|
this.wordReadingCounts.delete(entry.word);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (entry.reading) {
|
if (entry.reading) {
|
||||||
const readingNotes = this.readingNoteIds.get(entry.reading);
|
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
|
||||||
if (readingNotes) {
|
if (nextReadingCount > 0) {
|
||||||
readingNotes.delete(noteId);
|
this.readingCounts.set(entry.reading, nextReadingCount);
|
||||||
if (readingNotes.size === 0) {
|
} else {
|
||||||
this.readingNoteIds.delete(entry.reading);
|
this.readingCounts.delete(entry.reading);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearInMemoryState(): void {
|
private clearInMemoryState(): void {
|
||||||
this.wordReadingNoteIds = new Map();
|
this.wordReadingCounts = new Map();
|
||||||
this.readingNoteIds = new Map();
|
this.readingCounts = new Map();
|
||||||
this.noteEntriesById = new Map();
|
this.noteEntriesById = new Map();
|
||||||
this.noteTierById = new Map();
|
|
||||||
this.knownWordsLastRefreshedAtMs = 0;
|
this.knownWordsLastRefreshedAtMs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,8 +675,8 @@ export class KnownWordCacheManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseKnownWordCacheState(JSON.parse(raw) as unknown);
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
if (!parsed) {
|
if (!this.isKnownWordCacheStateValid(parsed)) {
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
return;
|
return;
|
||||||
@@ -825,63 +689,48 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
switch (parsed.version) {
|
if (parsed.version === 3) {
|
||||||
case 1:
|
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
||||||
// v1 has no per-note snapshots to convert; refetch from Anki.
|
const noteId = Number.parseInt(noteIdKey, 10);
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||||
return;
|
continue;
|
||||||
case 2:
|
|
||||||
// Older states have no readings; load them reading-less (fail-open,
|
|
||||||
// matching the old behavior) but leave the cache marked stale so the
|
|
||||||
// next refresh upgrades entries with readings from Anki.
|
|
||||||
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
|
||||||
const noteId = Number.parseInt(noteIdKey, 10);
|
|
||||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const normalizedEntries = normalizeKnownWordEntryList(
|
|
||||||
words.map((word) => ({
|
|
||||||
word: this.normalizeKnownWordForLookup(word),
|
|
||||||
reading: null,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
if (normalizedEntries.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
|
||||||
this.addEntriesToIndexes(noteId, normalizedEntries);
|
|
||||||
}
|
}
|
||||||
this.knownWordsStateKey = parsed.scope;
|
const normalizedEntries = normalizeKnownWordEntryList(entries);
|
||||||
return;
|
if (normalizedEntries.length === 0) {
|
||||||
case 3:
|
continue;
|
||||||
case 4:
|
|
||||||
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
|
||||||
const noteId = Number.parseInt(noteIdKey, 10);
|
|
||||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const normalizedEntries = normalizeKnownWordEntryList(entries);
|
|
||||||
if (normalizedEntries.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
|
||||||
this.addEntriesToIndexes(noteId, normalizedEntries);
|
|
||||||
}
|
}
|
||||||
if (parsed.version === 4) {
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
for (const [noteIdKey, tier] of Object.entries(parsed.tiers)) {
|
this.addEntriesToCounts(normalizedEntries);
|
||||||
const noteId = Number.parseInt(noteIdKey, 10);
|
}
|
||||||
const sanitizedTier = sanitizeKnownWordMaturityTier(tier);
|
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||||
if (sanitizedTier && this.noteEntriesById.has(noteId)) {
|
this.knownWordsStateKey = parsed.scope;
|
||||||
this.noteTierById.set(noteId, sanitizedTier);
|
return;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
|
||||||
this.knownWordsStateKey = parsed.scope;
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
assertNever(parsed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.version === 2) {
|
||||||
|
// Older states have no readings; load them reading-less (fail-open,
|
||||||
|
// matching the old behavior) but leave the cache marked stale so the
|
||||||
|
// next refresh upgrades entries with readings from Anki.
|
||||||
|
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
||||||
|
const noteId = Number.parseInt(noteIdKey, 10);
|
||||||
|
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalizedEntries = normalizeKnownWordEntryList(
|
||||||
|
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
|
||||||
|
);
|
||||||
|
if (normalizedEntries.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
|
this.addEntriesToCounts(normalizedEntries);
|
||||||
|
}
|
||||||
|
this.knownWordsStateKey = parsed.scope;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 has no per-note snapshots to convert; refetch from Anki.
|
||||||
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
@@ -892,23 +741,17 @@ export class KnownWordCacheManager {
|
|||||||
private persistKnownWordCacheState(): void {
|
private persistKnownWordCacheState(): void {
|
||||||
try {
|
try {
|
||||||
const notes: Record<string, KnownWordEntry[]> = {};
|
const notes: Record<string, KnownWordEntry[]> = {};
|
||||||
const tiers: Record<string, KnownWordMaturityTier> = {};
|
|
||||||
for (const [noteId, entries] of this.noteEntriesById.entries()) {
|
for (const [noteId, entries] of this.noteEntriesById.entries()) {
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
notes[String(noteId)] = entries;
|
notes[String(noteId)] = entries;
|
||||||
const tier = this.noteTierById.get(noteId);
|
|
||||||
if (tier) {
|
|
||||||
tiers[String(noteId)] = tier;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: CurrentKnownWordCacheState = {
|
const state: KnownWordCacheStateV3 = {
|
||||||
version: 4,
|
version: 3,
|
||||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||||
scope: this.knownWordsStateKey,
|
scope: this.knownWordsStateKey,
|
||||||
notes,
|
notes,
|
||||||
tiers,
|
|
||||||
};
|
};
|
||||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -916,6 +759,48 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||||
|
if (typeof candidate.scope !== 'string') return false;
|
||||||
|
if (candidate.version !== 3) {
|
||||||
|
if (!Array.isArray(candidate.words)) return false;
|
||||||
|
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate.version === 2 || candidate.version === 3) {
|
||||||
|
if (
|
||||||
|
typeof candidate.notes !== 'object' ||
|
||||||
|
candidate.notes === null ||
|
||||||
|
Array.isArray(candidate.notes)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isValidNoteEntry =
|
||||||
|
candidate.version === 2
|
||||||
|
? (entry: unknown): boolean => typeof entry === 'string'
|
||||||
|
: (entry: unknown): boolean =>
|
||||||
|
typeof entry === 'object' &&
|
||||||
|
entry !== null &&
|
||||||
|
typeof (entry as KnownWordEntry).word === 'string' &&
|
||||||
|
((entry as KnownWordEntry).reading === null ||
|
||||||
|
typeof (entry as KnownWordEntry).reading === 'string');
|
||||||
|
if (
|
||||||
|
!Object.values(candidate.notes as Record<string, unknown>).every(
|
||||||
|
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private extractKnownWordEntriesFromNoteInfo(
|
private extractKnownWordEntriesFromNoteInfo(
|
||||||
noteInfo: KnownWordCacheNoteInfo,
|
noteInfo: KnownWordCacheNoteInfo,
|
||||||
preferredFields = this.getConfiguredFields(),
|
preferredFields = this.getConfiguredFields(),
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
|
||||||
import {
|
|
||||||
DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS,
|
|
||||||
buildKnownWordMaturityTierQueries,
|
|
||||||
classifyKnownWordNoteTier,
|
|
||||||
getKnownWordMaturityEnabled,
|
|
||||||
getMatureIntervalThresholdDays,
|
|
||||||
maxKnownWordMaturityTier,
|
|
||||||
sanitizeKnownWordMaturityTier,
|
|
||||||
} from './known-word-maturity';
|
|
||||||
|
|
||||||
function makeConfig(knownWords: AnkiConnectConfig['knownWords']): AnkiConnectConfig {
|
|
||||||
return { url: 'http://127.0.0.1:8765', knownWords } as AnkiConnectConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('maturity is enabled only when both highlight and maturity flags are on', () => {
|
|
||||||
assert.equal(
|
|
||||||
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: true })),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: false, maturityEnabled: true })),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: false })),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true })), false);
|
|
||||||
assert.equal(getKnownWordMaturityEnabled(makeConfig(undefined)), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mature threshold falls back to default for invalid values', () => {
|
|
||||||
assert.equal(DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS, 21);
|
|
||||||
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 30 })), 30);
|
|
||||||
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 14.9 })), 14);
|
|
||||||
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 0 })), 21);
|
|
||||||
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: -5 })), 21);
|
|
||||||
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: Number.NaN })), 21);
|
|
||||||
assert.equal(getMatureIntervalThresholdDays(makeConfig({})), 21);
|
|
||||||
assert.equal(getMatureIntervalThresholdDays(makeConfig(undefined)), 21);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tier queries append Anki search props to a deck scope query', () => {
|
|
||||||
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
|
|
||||||
assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21 -is:learn');
|
|
||||||
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn');
|
|
||||||
assert.equal(queries.learning, 'deck:"Mining" is:learn');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('interval tiers exclude (re)learning cards so the buckets stay disjoint', () => {
|
|
||||||
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
|
|
||||||
// A lapsed card keeps an interval of at least the lapse minInt (>= 1), so
|
|
||||||
// without the exclusion the young query would claim every relearning card
|
|
||||||
// and the learning tier could only ever match brand-new cards mid-step.
|
|
||||||
for (const intervalQuery of [queries.mature, queries.young]) {
|
|
||||||
assert.ok(intervalQuery.includes('-is:learn'));
|
|
||||||
}
|
|
||||||
assert.equal(queries.learning, 'deck:"Mining" is:learn');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tier queries with an empty scope query have no leading space', () => {
|
|
||||||
const queries = buildKnownWordMaturityTierQueries('', 30);
|
|
||||||
assert.equal(queries.mature, 'prop:ivl>=30 -is:learn');
|
|
||||||
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30 -is:learn');
|
|
||||||
assert.equal(queries.learning, 'is:learn');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('note classification picks the most mature matching tier', () => {
|
|
||||||
const sets = {
|
|
||||||
mature: new Set([1, 4]),
|
|
||||||
young: new Set([2, 4]),
|
|
||||||
learning: new Set([3, 4, 2]),
|
|
||||||
};
|
|
||||||
assert.equal(classifyKnownWordNoteTier(1, sets), 'mature');
|
|
||||||
assert.equal(classifyKnownWordNoteTier(2, sets), 'young');
|
|
||||||
assert.equal(classifyKnownWordNoteTier(3, sets), 'learning');
|
|
||||||
// Note with mature, young, and learning cards: most mature card wins.
|
|
||||||
assert.equal(classifyKnownWordNoteTier(4, sets), 'mature');
|
|
||||||
assert.equal(classifyKnownWordNoteTier(99, sets), 'new');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('maxKnownWordMaturityTier picks the higher tier and tolerates null', () => {
|
|
||||||
assert.equal(maxKnownWordMaturityTier('mature', 'new'), 'mature');
|
|
||||||
assert.equal(maxKnownWordMaturityTier('learning', 'young'), 'young');
|
|
||||||
assert.equal(maxKnownWordMaturityTier('new', null), 'new');
|
|
||||||
assert.equal(maxKnownWordMaturityTier(null, 'learning'), 'learning');
|
|
||||||
assert.equal(maxKnownWordMaturityTier(null, null), null);
|
|
||||||
assert.equal(maxKnownWordMaturityTier(undefined, undefined), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sanitizeKnownWordMaturityTier accepts only valid tiers', () => {
|
|
||||||
assert.equal(sanitizeKnownWordMaturityTier('mature'), 'mature');
|
|
||||||
assert.equal(sanitizeKnownWordMaturityTier('young'), 'young');
|
|
||||||
assert.equal(sanitizeKnownWordMaturityTier('learning'), 'learning');
|
|
||||||
assert.equal(sanitizeKnownWordMaturityTier('new'), 'new');
|
|
||||||
assert.equal(sanitizeKnownWordMaturityTier('MATURE'), null);
|
|
||||||
assert.equal(sanitizeKnownWordMaturityTier(''), null);
|
|
||||||
assert.equal(sanitizeKnownWordMaturityTier(21), null);
|
|
||||||
assert.equal(sanitizeKnownWordMaturityTier(null), null);
|
|
||||||
assert.equal(sanitizeKnownWordMaturityTier(undefined), null);
|
|
||||||
});
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import type { AnkiConnectConfig } from '../types/anki';
|
|
||||||
import type { KnownWordMaturityTier } from '../types/subtitle';
|
|
||||||
|
|
||||||
export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21;
|
|
||||||
|
|
||||||
// Version of the tier classification rules; part of the known-word cache
|
|
||||||
// identity so a rule change invalidates caches built under the old rules.
|
|
||||||
export const KNOWN_WORD_MATURITY_RULES_VERSION = 2;
|
|
||||||
|
|
||||||
// Ascending maturity; index order backs tier comparison.
|
|
||||||
const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
|
|
||||||
|
|
||||||
export interface KnownWordMaturityTierQueries {
|
|
||||||
mature: string;
|
|
||||||
young: string;
|
|
||||||
learning: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KnownWordMaturityTierSets {
|
|
||||||
mature: ReadonlySet<number>;
|
|
||||||
young: ReadonlySet<number>;
|
|
||||||
learning: ReadonlySet<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maturity tiers only affect how known-word highlights render, so both flags
|
|
||||||
// must be on before tier data is fetched or served.
|
|
||||||
export function getKnownWordMaturityEnabled(config: AnkiConnectConfig): boolean {
|
|
||||||
return (
|
|
||||||
config.knownWords?.highlightEnabled === true && config.knownWords?.maturityEnabled === true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMatureIntervalThresholdDays(config: AnkiConnectConfig): number {
|
|
||||||
const threshold = config.knownWords?.matureThresholdDays;
|
|
||||||
if (typeof threshold === 'number' && Number.isFinite(threshold) && threshold >= 1) {
|
|
||||||
return Math.floor(threshold);
|
|
||||||
}
|
|
||||||
return DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anki search props classify notes server-side: a note matches a tier query
|
|
||||||
// when ANY of its cards matches, which implements most-mature-card-wins for
|
|
||||||
// free once tiers are checked in mature > young > learning order.
|
|
||||||
//
|
|
||||||
// The interval tiers exclude is:learn so the per-card buckets stay disjoint and
|
|
||||||
// match Anki's own card counts, where (re)learning is its own bucket rather
|
|
||||||
// than part of young/mature. Without the exclusion a lapsed card - whose
|
|
||||||
// interval is reset to at least lapse minInt, so >= 1 - is caught by the young
|
|
||||||
// query first and the learning tier becomes unreachable in practice.
|
|
||||||
export function buildKnownWordMaturityTierQueries(
|
|
||||||
scopeQuery: string,
|
|
||||||
thresholdDays: number,
|
|
||||||
): KnownWordMaturityTierQueries {
|
|
||||||
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
|
|
||||||
return {
|
|
||||||
mature: `${prefix}prop:ivl>=${thresholdDays} -is:learn`,
|
|
||||||
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays} -is:learn`,
|
|
||||||
learning: `${prefix}is:learn`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchKnownWordMaturityTierSets(
|
|
||||||
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>,
|
|
||||||
scopeQueries: string[],
|
|
||||||
thresholdDays: number,
|
|
||||||
): Promise<{ mature: Set<number>; young: Set<number>; learning: Set<number> }> {
|
|
||||||
const sets = {
|
|
||||||
mature: new Set<number>(),
|
|
||||||
young: new Set<number>(),
|
|
||||||
learning: new Set<number>(),
|
|
||||||
};
|
|
||||||
for (const scopeQuery of scopeQueries) {
|
|
||||||
const queries = buildKnownWordMaturityTierQueries(scopeQuery, thresholdDays);
|
|
||||||
for (const tier of ['mature', 'young', 'learning'] as const) {
|
|
||||||
const noteIds = (await findNotes(queries[tier], { maxRetries: 0 })) as number[];
|
|
||||||
if (!Array.isArray(noteIds)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const noteId of noteIds) {
|
|
||||||
if (Number.isInteger(noteId) && noteId > 0) {
|
|
||||||
sets[tier].add(noteId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sets;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function classifyKnownWordNoteTier(
|
|
||||||
noteId: number,
|
|
||||||
sets: KnownWordMaturityTierSets,
|
|
||||||
): KnownWordMaturityTier {
|
|
||||||
if (sets.mature.has(noteId)) return 'mature';
|
|
||||||
if (sets.young.has(noteId)) return 'young';
|
|
||||||
if (sets.learning.has(noteId)) return 'learning';
|
|
||||||
return 'new';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maxKnownWordMaturityTier(
|
|
||||||
a: KnownWordMaturityTier | null | undefined,
|
|
||||||
b: KnownWordMaturityTier | null | undefined,
|
|
||||||
): KnownWordMaturityTier | null {
|
|
||||||
if (!a) return b ?? null;
|
|
||||||
if (!b) return a;
|
|
||||||
return TIER_ORDER.indexOf(a) >= TIER_ORDER.indexOf(b) ? a : b;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeKnownWordMaturityTier(value: unknown): KnownWordMaturityTier | null {
|
|
||||||
return typeof value === 'string' && TIER_ORDER.includes(value as KnownWordMaturityTier)
|
|
||||||
? (value as KnownWordMaturityTier)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user