mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0360270e81
|
||
|
|
a8e929eb26
|
||
|
|
b31ddce627 | ||
|
|
79e5535823
|
||
|
|
5140b5e9be
|
||
|
|
c2dac537da
|
||
|
|
bc7e82fee6
|
||
|
|
c3e672efdb
|
||
|
|
269c0708cb | ||
|
|
f89f78b861 | ||
|
|
14ef7e823c | ||
|
|
a8bc585f75
|
||
|
|
5f635f3042
|
||
|
|
7c637149dd | ||
|
|
33f5f890d5 | ||
|
|
d8c2315405 | ||
|
|
8976d78390
|
||
|
|
1c50d249e0 | ||
|
|
8e29cb3c55 | ||
|
|
fa3051f9cb | ||
|
|
848a6c4021 | ||
|
|
b99409bbcd | ||
|
|
6d0f37ac21 | ||
|
|
696462a82e | ||
|
|
655510cf2e | ||
|
|
af44735bb8 | ||
|
|
804fb8933e
|
||
|
|
2924f04316 | ||
|
|
a9a4c4f083 | ||
|
|
90fc7cf940 | ||
|
|
2a0e931325 | ||
|
|
0129e03c0f
|
||
|
|
586d7950cf
|
||
|
|
33dceabc3e
|
||
|
|
ad1abc3268
|
||
|
|
e59b78dca0 |
No files matched your search
@@ -56,7 +56,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Install mpv and ffmpeg (macOS)
|
- name: Install mpv and ffmpeg (macOS)
|
||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
run: brew install mpv ffmpeg
|
run: |
|
||||||
|
# Runner images bake in different Homebrew metadata, so without an update the
|
||||||
|
# installed mpv build depends on which image the job lands on.
|
||||||
|
brew update
|
||||||
|
brew install mpv ffmpeg
|
||||||
|
mpv --version
|
||||||
|
|
||||||
- name: Install mpv and ffmpeg (Windows)
|
- name: Install mpv and ffmpeg (Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
|
||||||
@@ -49,7 +49,7 @@ Start here, then leave this file.
|
|||||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
||||||
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
||||||
- macOS mpv window helper (`scripts/get-mpv-window-macos.swift`): compile it on macOS with `bun run build`
|
- macOS Swift helpers (`scripts/get-mpv-window-macos.swift`, `scripts/macos-controller-watch.swift`): compile them on macOS with `bun run build`
|
||||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||||
|
|
||||||
## Docs Upkeep
|
## Docs Upkeep
|
||||||
|
|||||||
@@ -1,5 +1,55 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.22.0 (2026-11-06)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Plugins**:
|
||||||
|
- JavaScript plugins load at startup from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
|
||||||
|
- Plugins can react to playback and mined cards, add bindable actions, run programs, and show results in overlay panels or their own windows.
|
||||||
|
- Example plugins cover AI line explanations (`ai-explain`), AnkiWeb sync (`anki-sync`), and show and episode tags on mined cards (`episode-tags`).
|
||||||
|
- Plugin authors get a versioned API with standalone types.
|
||||||
|
- **Subtitle Filters**: Remove text in parentheses, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
|
||||||
|
- **Controller Combos**: Controller actions can now be combos, such as holding `L1` and pressing D-pad right. Click **Learn** in `Alt+C`, then hold one button and press another.
|
||||||
|
- **Controller Actions**: New bindings cover previous, next, replay, and play-next subtitle, hold-to-show secondary subtitle, and previous or next Yomitan entry. All are unbound by default and can be assigned in `Alt+C`.
|
||||||
|
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be used with a controller and show button hints while one is connected. Remap both layouts per controller in `Alt+C`.
|
||||||
|
- **Controller Button Style**: Choose Xbox, Nintendo, or PlayStation button names per controller in `Alt+C`, or set a default with `controller.faceButtonLabels`.
|
||||||
|
- **Controller Clash Warnings**: `Alt+C` now warns when two actions share the same input.
|
||||||
|
- **Keyboard-Only Mode**:
|
||||||
|
- Press `C` or use the new controller binding to move by character, reaching words split by the subtitle parser.
|
||||||
|
- The new **Pause Video In Keyboard-Only Mode** setting pauses on entry and resumes on exit.
|
||||||
|
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Reopening Jimaku after the video changes now refreshes the title and episode.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Stats Trends and Vocabulary**:
|
||||||
|
- Trends adds a learning section, heatmap, title timeline, custom date ranges, and a page-wide title filter.
|
||||||
|
- Vocabulary now has one searchable word table, known words by frequency band, a growth chart, and a full kanji view.
|
||||||
|
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
|
||||||
|
- Stats now explains when streamed words or lines can't be mined.
|
||||||
|
- **Bundled Hachidori**: Updated to upstream 0.4.0, adding every pitch accent in popup entries, per-kanji headword furigana, and Yomitan-style definition tags.
|
||||||
|
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Connected and disabled messages now follow your `ankiConnect.behavior.notificationType` setting.
|
||||||
|
- **Dictionary Popup**: Turning keyboard-only mode off now also closes the popup.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Controller Settings (`Alt+C`)**:
|
||||||
|
- Remaps now apply right away, and Learn works on **(D-pad)** rows.
|
||||||
|
- Clicking a row shows **Learn**, **Clear**, and **Reset** again.
|
||||||
|
- The window previews your bindings until a press, then selects the pressed controller.
|
||||||
|
- Button names are now correct on standard-layout controllers.
|
||||||
|
- **Controller Input on macOS and Windows**: One press no longer runs its action twice. On macOS, buttons now work while mpv is in front.
|
||||||
|
- **Dictionary Popup**: Keyboard and controller lookups now pause and resume playback reliably. Moving between words right after closing a popup no longer reopens it.
|
||||||
|
- **Playback**: Reloading the current file no longer leaves playback paused. The "Overlay loading" message no longer sticks or turns into a false error when a playlist advances.
|
||||||
|
- **Stats**:
|
||||||
|
- The dashboard, Vocabulary tab, and sentence search load much faster on large databases, and Trends downloads far less data.
|
||||||
|
- Trends now counts each finished episode once, and its Library totals match the selected range.
|
||||||
|
- Word totals, readings, Hide Names, and rate charts are now accurate.
|
||||||
|
- Date handling is now correct in time zones ahead of UTC.
|
||||||
|
- **AniList**: Progress sync now adds a missing show to Watching, or Completed on the final episode, when the title matches exactly or is pinned. Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
|
||||||
|
- **Anki Sentence Audio**: Generated sentence audio no longer overwrites Yomitan's word audio on default configs. If you relied on the old default, set `ankiConnect.fields.audio` to `ExpressionAudio`.
|
||||||
|
- **AppImage**: Fixed "Service Crash" notifications on quit on Ubuntu 26.04 and newer.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
|
||||||
|
|
||||||
## v0.21.1 (2026-10-26)
|
## v0.21.1 (2026-10-26)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
|
|||||||
<td><b>WebSocket</b></td>
|
<td><b>WebSocket</b></td>
|
||||||
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>Plugins</b></td>
|
||||||
|
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. Bundled examples explain the current line with Codex, sync Anki to AnkiWeb, and tag mined cards by episode. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -267,7 +271,7 @@ SubMiner builds on the work of these open-source projects:
|
|||||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
|
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
|
||||||
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
|
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
|
||||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Sends the current subtitle line, with a few lines of context, to `codex exec` and shows the
|
||||||
|
// answer in the SubMiner plugin panel. Registers two actions: `explain` and `translate`.
|
||||||
|
const { mkdtemp, readFile, rm } = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
codexPath: 'codex',
|
||||||
|
model: '',
|
||||||
|
// Passed as -c model_reasoning_effort; low keeps answers to a few seconds. Empty uses your Codex config.
|
||||||
|
reasoningEffort: 'low',
|
||||||
|
before: 2,
|
||||||
|
after: 1,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
pause: true,
|
||||||
|
prompts: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIONS = {
|
||||||
|
explain: {
|
||||||
|
title: 'Grammar',
|
||||||
|
prompt:
|
||||||
|
'You are a Japanese tutor. Explain the line marked >>> for an intermediate learner. ' +
|
||||||
|
'Use the other lines only as context. Reply in English markdown: a natural translation, ' +
|
||||||
|
'then a bullet list of the grammar points and notable vocabulary. Be concise.',
|
||||||
|
},
|
||||||
|
translate: {
|
||||||
|
title: 'Translation',
|
||||||
|
prompt:
|
||||||
|
'Translate the line marked >>> into natural English. Use the other lines only as context. ' +
|
||||||
|
'Reply with the translation, then one short note only if the line is ambiguous.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function activate(ctx) {
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||||
|
for (const [name, action] of Object.entries(ACTIONS)) {
|
||||||
|
const prompt = settings.prompts?.[name] ?? action.prompt;
|
||||||
|
ctx.actions.register(name, () => ask(ctx, settings, action.title, prompt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quote(text) {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => `> ${line}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitles wrap across lines; flatten each cue so the >>> marker covers all of it.
|
||||||
|
function oneLine(text) {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail(text, lineCount) {
|
||||||
|
return text.trim().split('\n').slice(-lineCount).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(ctx, settings, title, instructions) {
|
||||||
|
const { before, current, after } = ctx.subtitles.context({
|
||||||
|
before: settings.before,
|
||||||
|
after: settings.after,
|
||||||
|
});
|
||||||
|
if (!current) {
|
||||||
|
ctx.notify('No subtitle line to send.', { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settings.pause) ctx.mpv.command(['set', 'pause', 'yes']);
|
||||||
|
|
||||||
|
const header = quote(current.text);
|
||||||
|
const panel = ctx.ui.panel({ title, markdown: `${header}\n\n_Asking Codex..._` });
|
||||||
|
const prompt = [
|
||||||
|
instructions,
|
||||||
|
'',
|
||||||
|
'Subtitles:',
|
||||||
|
...before.map((line) => oneLine(line.text)),
|
||||||
|
`>>> ${oneLine(current.text)}`,
|
||||||
|
...after.map((line) => oneLine(line.text)),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Codex runs in an empty read-only directory so any command it tries cannot touch real files.
|
||||||
|
const workDir = await mkdtemp(path.join(os.tmpdir(), 'subminer-ai-explain-'));
|
||||||
|
const answerPath = path.join(workDir, 'answer.md');
|
||||||
|
const args = [
|
||||||
|
'exec',
|
||||||
|
'--ephemeral',
|
||||||
|
'--skip-git-repo-check',
|
||||||
|
'--sandbox',
|
||||||
|
'read-only',
|
||||||
|
'--color',
|
||||||
|
'never',
|
||||||
|
'-C',
|
||||||
|
workDir,
|
||||||
|
'-o',
|
||||||
|
answerPath,
|
||||||
|
];
|
||||||
|
if (settings.model) args.push('-m', settings.model);
|
||||||
|
if (settings.reasoningEffort) {
|
||||||
|
args.push('-c', `model_reasoning_effort=${JSON.stringify(settings.reasoningEffort)}`);
|
||||||
|
}
|
||||||
|
args.push('-');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ctx.exec(settings.codexPath, args, {
|
||||||
|
stdin: prompt,
|
||||||
|
timeoutMs: settings.timeoutMs,
|
||||||
|
signal: panel.signal,
|
||||||
|
});
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(tail(result.stderr, 6) || `codex exited with code ${result.exitCode}`);
|
||||||
|
}
|
||||||
|
const answer = (await readFile(answerPath, 'utf8')).trim();
|
||||||
|
panel.update({ markdown: `${header}\n\n${answer || '_Codex returned an empty answer._'}` });
|
||||||
|
} catch (error) {
|
||||||
|
// Closing the panel aborts the request; there is nobody left to show the error to.
|
||||||
|
if (panel.signal.aborted) return;
|
||||||
|
const message =
|
||||||
|
error?.code === 'ENOENT'
|
||||||
|
? `Cannot find \`${settings.codexPath}\`. Set \`plugins.ai-explain.settings.codexPath\` to its full path.`
|
||||||
|
: String(error?.message ?? error);
|
||||||
|
ctx.log.warn(`Codex request failed: ${message}`);
|
||||||
|
panel.update({
|
||||||
|
markdown: `${header}\n\n**Codex request failed.**\n\n\`\`\`\n${message}\n\`\`\``,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { activate };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "AI Explain",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": 1,
|
||||||
|
"main": "main.js"
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Runs AnkiConnect's `sync` (AnkiWeb) after you mine cards. A burst of mining becomes one sync:
|
||||||
|
// it waits until no card has been mined for `delayMs`, and syncs right away when playback ends.
|
||||||
|
// Also registers a `sync` action for a manual sync.
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
delayMs: 30_000,
|
||||||
|
syncOnEnd: true,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
notify: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const END_REASONS = new Set(['eof', 'stop', 'quit']);
|
||||||
|
|
||||||
|
function activate(ctx) {
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||||
|
// Cards mined since the last sync started.
|
||||||
|
let dirty = false;
|
||||||
|
let syncing = false;
|
||||||
|
let cancelTimer = null;
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
cancelTimer?.();
|
||||||
|
cancelTimer = ctx.after(settings.delayMs, flush);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
cancelTimer?.();
|
||||||
|
cancelTimer = null;
|
||||||
|
// A sync already running picks up new cards when it finishes.
|
||||||
|
if (!dirty || syncing) return;
|
||||||
|
dirty = false;
|
||||||
|
syncing = true;
|
||||||
|
try {
|
||||||
|
await ctx.anki.invoke('sync', {}, { timeoutMs: settings.timeoutMs });
|
||||||
|
ctx.log.info('Synced Anki.');
|
||||||
|
if (settings.notify) ctx.notify('Anki synced');
|
||||||
|
} catch (error) {
|
||||||
|
ctx.log.warn(`Anki sync failed: ${error.message}`);
|
||||||
|
ctx.notify(`Anki sync failed: ${error.message}`, { variant: 'error' });
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
if (dirty) schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.anki.on('card-mined', () => {
|
||||||
|
dirty = true;
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.mpv.on('end-file', ({ reason }) => {
|
||||||
|
if (settings.syncOnEnd && END_REASONS.has(reason)) return flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.actions.register('sync', () => {
|
||||||
|
dirty = true;
|
||||||
|
return flush();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { activate };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Anki Sync",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": 1,
|
||||||
|
"main": "main.js"
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Tags each mined card with the show and episode it came from, e.g. `show::Frieren::S01E07`.
|
||||||
|
// The show and episode are parsed from the file name, or from mpv's media title for streams.
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
// Placeholders: {show}, {season}, {episode}, and {ep} (S01E07, or E07 without a season).
|
||||||
|
// A tag stops before the first `::` level whose placeholder has no value.
|
||||||
|
tags: ['show::{show}::{ep}'],
|
||||||
|
notify: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tried in order; the first match wins. Runs on names with brackets and dots already removed.
|
||||||
|
const EPISODE_PATTERNS = [
|
||||||
|
/^(?<show>.*?)\s*S(?<season>\d{1,2})\s?E(?<episode>\d{1,4})\b/i,
|
||||||
|
/^(?<show>.*?)\s*(?:Season\s?(?<season>\d{1,2})\s*)?(?:Episode|Ep)\s?(?<episode>\d{1,4})\b/i,
|
||||||
|
/^(?<show>.*?)\s+-\s+(?<episode>\d{1,4})(?:v\d)?\b/,
|
||||||
|
/^(?<show>.*?)\s+(?<episode>\d{1,4})(?:v\d)?$/,
|
||||||
|
/^(?<show>.*?)\s*第(?<episode>\d{1,4})話/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// `[Group] Show Name - 07 (1080p).mkv` -> { show: 'Show Name', season: null, episode: '07' }
|
||||||
|
function parseMediaName(name) {
|
||||||
|
const cleaned = name
|
||||||
|
.replace(/\.[a-z0-9]{2,4}$/i, '')
|
||||||
|
.replace(/\[[^\]]*\]|\([^)]*\)|【[^】]*】/g, ' ')
|
||||||
|
.replace(/[._]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
for (const pattern of EPISODE_PATTERNS) {
|
||||||
|
const groups = cleaned.match(pattern)?.groups;
|
||||||
|
const show = groups?.show.replace(/[\s-]+$/, '');
|
||||||
|
if (!show) continue;
|
||||||
|
return { show, season: pad(groups.season), episode: pad(groups.episode) };
|
||||||
|
}
|
||||||
|
return { show: cleaned || null, season: null, episode: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(number) {
|
||||||
|
return number ? String(Number(number)).padStart(2, '0') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anki tags can't hold spaces, and a `::` in a value would add a hierarchy level.
|
||||||
|
function tagValue(value) {
|
||||||
|
return value.replace(/::?/g, ' ').trim().replace(/\s+/g, '_').replace(/"/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTag(template, values) {
|
||||||
|
const levels = [];
|
||||||
|
for (const level of template.split('::')) {
|
||||||
|
let missing = false;
|
||||||
|
const rendered = level.replace(/\{(\w+)\}/g, (_, key) => {
|
||||||
|
const value = values[key];
|
||||||
|
if (!value) missing = true;
|
||||||
|
return value ? tagValue(value) : '';
|
||||||
|
});
|
||||||
|
if (missing) break;
|
||||||
|
levels.push(rendered);
|
||||||
|
}
|
||||||
|
return levels.join('::');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaName(mediaPath, title) {
|
||||||
|
// A stream URL's last segment is usually an id, so streams use the title mpv shows.
|
||||||
|
if (typeof mediaPath === 'string' && mediaPath && !/^[a-z]+:\/\//i.test(mediaPath)) {
|
||||||
|
return path.basename(mediaPath);
|
||||||
|
}
|
||||||
|
return typeof title === 'string' && title ? title : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTags(templates, name) {
|
||||||
|
const { show, season, episode } = parseMediaName(name);
|
||||||
|
const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null;
|
||||||
|
const values = { show, season, episode, ep };
|
||||||
|
return [...new Set(templates.map((template) => renderTag(template, values)))].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(ctx) {
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||||
|
ctx.anki.on('card-mined', async ({ notes }) => {
|
||||||
|
// Notes from the same file share one addTags call.
|
||||||
|
const notesByName = new Map();
|
||||||
|
for (const { noteId, provenance } of notes) {
|
||||||
|
const name = mediaName(provenance?.media?.path, provenance?.media?.title);
|
||||||
|
if (!name) {
|
||||||
|
ctx.log.debug(`Note ${noteId} has no known media source; not tagging it.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!notesByName.has(name)) notesByName.set(name, new Set());
|
||||||
|
notesByName.get(name).add(noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, notes] of notesByName) {
|
||||||
|
const tags = renderTags(settings.tags, name);
|
||||||
|
if (tags.length === 0) {
|
||||||
|
ctx.log.debug(`No tags for "${name}".`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ctx.anki.invoke('addTags', { notes: [...notes], tags: tags.join(' ') });
|
||||||
|
ctx.log.info(`Tagged ${notes.size} note(s): ${tags.join(' ')}`);
|
||||||
|
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.log.warn(`Tagging failed: ${error.message}`);
|
||||||
|
ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { activate };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Episode Tags",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": 1,
|
||||||
|
"main": "main.js"
|
||||||
|
}
|
||||||
@@ -28,16 +28,13 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
|
||||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch",
|
|
||||||
},
|
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.15",
|
"@xmldom/xmldom": "0.8.15",
|
||||||
"app-builder-lib": "26.16.1",
|
"app-builder-lib": "26.16.1",
|
||||||
"brace-expansion": "5.0.11",
|
"brace-expansion": "5.0.11",
|
||||||
"electron-builder-squirrel-windows": "26.16.1",
|
"electron-builder-squirrel-windows": "26.16.1",
|
||||||
"fast-uri": "3.1.7",
|
"fast-uri": "3.1.7",
|
||||||
"follow-redirects": "1.16.1",
|
"follow-redirects": "1.16.2",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.2",
|
"js-yaml": "4.3.2",
|
||||||
@@ -351,7 +348,7 @@
|
|||||||
|
|
||||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||||
|
|
||||||
"follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="],
|
"follow-redirects": ["follow-redirects@1.16.2", "", {}, "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow=="],
|
||||||
|
|
||||||
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
"form-data": ["form-data@4.0.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=="],
|
||||||
|
|
||||||
|
|||||||
+344
-16
@@ -108,6 +108,7 @@
|
|||||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||||
|
"faceButtonLabels": "xbox", // Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others. Values: xbox | nintendo | playstation
|
||||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||||
"buttonIndices": {
|
"buttonIndices": {
|
||||||
@@ -127,38 +128,62 @@
|
|||||||
"toggleLookup": {
|
"toggleLookup": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"closeLookup": {
|
"closeLookup": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"toggleKeyboardOnlyMode": {
|
"toggleKeyboardOnlyMode": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"mineCard": {
|
"mineCard": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"quitMpv": {
|
"quitMpv": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"previousAudio": {
|
"previousAudio": {
|
||||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"nextAudio": {
|
"nextAudio": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"playCurrentAudio": {
|
"playCurrentAudio": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"toggleMpvPause": {
|
"toggleMpvPause": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"holdSecondarySub": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"previousEntry": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"nextEntry": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"toggleCharacterNavigation": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"previousSubtitle": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"nextSubtitle": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"replaySubtitle": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"playNextSubtitle": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"leftStickHorizontal": {
|
"leftStickHorizontal": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||||
@@ -169,17 +194,294 @@
|
|||||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
||||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
"rightStickHorizontal": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
"rightStickVertical": {
|
"rightStickVertical": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||||
|
"reviewBindings": {
|
||||||
|
"confirm": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Use this timing.
|
||||||
|
"cancel": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Cancel.
|
||||||
|
"play": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Play selection.
|
||||||
|
"resetTiming": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Reset timing.
|
||||||
|
"showEarlier": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Earlier −2s. Repeats while held.
|
||||||
|
"showLater": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Later +2s. Repeats while held.
|
||||||
|
"framePrevious": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Previous screenshot frame. Repeats while held.
|
||||||
|
"frameNext": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Next screenshot frame. Repeats while held.
|
||||||
|
"frameReset": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Reset screenshot frame.
|
||||||
|
"startBack": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Start −0.1s. Repeats while held.
|
||||||
|
"startForward": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Start +0.1s. Repeats while held.
|
||||||
|
"previousLineAdd": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Add previous line. Repeats while held.
|
||||||
|
"previousLineRemove": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Remove previous line. Repeats while held.
|
||||||
|
"endBack": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: End −0.1s. Repeats while held.
|
||||||
|
"endForward": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: End +0.1s. Repeats while held.
|
||||||
|
"nextLineAdd": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Add next line. Repeats while held.
|
||||||
|
"nextLineRemove": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Remove next line. Repeats while held.
|
||||||
|
"useOriginal": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Use original timing (after cancel).
|
||||||
|
"keepEditing": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Keep editing (after cancel).
|
||||||
|
"skipMedia": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Without media (after cancel).
|
||||||
|
"discard": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
} // Review dialog: Discard card (after cancel).
|
||||||
|
}, // Controller layout for the media timing review dialog, one entry per action. Set it with the Review media timing group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
|
||||||
|
"fieldGroupingBindings": {
|
||||||
|
"selectOriginal": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Original card.
|
||||||
|
"selectNew": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: New card.
|
||||||
|
"toggleDeleteDuplicate": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Delete duplicate on/off.
|
||||||
|
"continue": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Continue.
|
||||||
|
"cancel": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Cancel.
|
||||||
|
"confirmMerge": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Confirm merge (merge preview).
|
||||||
|
"back": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Back (merge preview).
|
||||||
|
"cancelMerge": {
|
||||||
|
"kind": "none" // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
}, // Duplicate card dialog: Cancel (merge preview).
|
||||||
|
"toggleView": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Compact / Full (merge preview).
|
||||||
|
"scrollUp": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Scroll up (merge preview). Repeats while held.
|
||||||
|
"scrollDown": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
} // Duplicate card dialog: Scroll down (merge preview). Repeats while held.
|
||||||
|
}, // Controller layout for the duplicate card (field grouping) dialog, one entry per action. Set it with the Duplicate card (field grouping) group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
|
||||||
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
@@ -218,6 +520,14 @@
|
|||||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||||
}, // Overlay notification display behavior.
|
}, // Overlay notification display behavior.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Plugins
|
||||||
|
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
|
||||||
|
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
|
||||||
|
// Restart SubMiner after changing plugins.
|
||||||
|
// ==========================================
|
||||||
|
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcuts
|
||||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
@@ -462,6 +772,7 @@
|
|||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
|
"autoPauseVideoOnKeyboardMode": false, // Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is. Values: true | false
|
||||||
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
|
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
|
||||||
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
|
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
|
||||||
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
||||||
@@ -541,6 +852,22 @@
|
|||||||
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
|
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
|
||||||
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Subtitle Filters
|
||||||
|
// Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
|
||||||
|
// Applies to the overlay, sidebar, mined sentences and stats.
|
||||||
|
// Hot-reload: filter changes apply to the current subtitle file immediately.
|
||||||
|
// ==========================================
|
||||||
|
"subtitleFilter": {
|
||||||
|
"presets": {
|
||||||
|
"parentheses": false, // Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept. Values: true | false
|
||||||
|
"brackets": false, // Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues. Values: true | false
|
||||||
|
"speakerPrefix": false, // Remove a short name followed by a colon at the start of a line, as in 山田:元気? Values: true | false
|
||||||
|
"musicSymbols": false // Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed. Values: true | false
|
||||||
|
}, // Built-in filters. Each removes its matches and is off by default.
|
||||||
|
"customRules": [] // Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.
|
||||||
|
}, // Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Shared AI Provider
|
// Shared AI Provider
|
||||||
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||||
@@ -578,7 +905,7 @@
|
|||||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
"audio": "SentenceAudio", // Card field that receives generated sentence audio.
|
||||||
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
|
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
|
||||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||||
@@ -663,7 +990,8 @@
|
|||||||
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
||||||
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
||||||
|
"autoOpenWhenPrimarySubtitleMissing": false // Pause playback and open the Jimaku modal when a local video has no subtitle track in youtube.primarySubLanguages (Japanese by default). Values: true | false
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
|||||||
{ 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' },
|
||||||
|
{ text: 'Plugins', link: '/plugins' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ An episode counts as watched after 85% of its length and at least 10 minutes of
|
|||||||
2. Finds the matching AniList entry. For season 2 and later, it follows the show's sequels.
|
2. Finds the matching AniList entry. For season 2 and later, it follows the show's sequels.
|
||||||
3. Sets your progress to that episode and marks the entry Watching, or Completed on the final episode.
|
3. Sets your progress to that episode and marks the entry Watching, or Completed on the final episode.
|
||||||
|
|
||||||
The show must already be on your Planning or Watching list. SubMiner does not add new entries, and it never lowers your progress.
|
If the show is not on your list yet, SubMiner adds it as Watching, but only when the file title exactly matches an AniList title or you pinned the entry with the character dictionary's AniList override. For a looser match, add the show to Planning or Watching yourself. SubMiner never changes entries marked Completed, Paused, Dropped, or Rewatching, and it never lowers your progress.
|
||||||
|
|
||||||
Failed updates are saved and retried in the background, up to 8 times with growing delays. The queue survives restarts.
|
Failed updates are saved and retried in the background, up to 8 times with growing delays. The queue survives restarts.
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ Character dictionary settings live under `anilist.characterDictionary` and are c
|
|||||||
|
|
||||||
**No update after an episode.** Check that `anilist.enabled` is `true` and that you watched at least 85% of the episode.
|
**No update after an episode.** Check that `anilist.enabled` is `true` and that you watched at least 85% of the episode.
|
||||||
|
|
||||||
**"AniList update not possible."** Add the show to your Planning or Watching list, then mark the episode watched again.
|
**"AniList update not possible."** The show is marked Completed, Paused, Dropped, or Rewatching, or it is not on your list and the title match was not exact. Add the show to your Planning or Watching list, or pin it with the character dictionary's AniList override, then mark the episode watched again.
|
||||||
|
|
||||||
**Wrong show or episode.** Install guessit and make sure it is on your `PATH`. Unusual file names parse poorly without it.
|
**Wrong show or episode.** Install guessit and make sure it is on your `PATH`. Unusual file names parse poorly without it.
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Field names are matched case-insensitively. A mapped field that is missing from
|
|||||||
|
|
||||||
Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold.
|
Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold.
|
||||||
|
|
||||||
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note, so point `fields.audio` at a separate field such as `SentenceAudio`. The default, `ExpressionAudio`, is the field many note types use for Yomitan's word audio, so leaving it would overwrite that audio.
|
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note (often `ExpressionAudio`), so keep `fields.audio` pointed at a separate field such as the default `SentenceAudio`. Pointing it at Yomitan's word-audio field would overwrite that audio.
|
||||||
|
|
||||||
`ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none.
|
`ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none.
|
||||||
|
|
||||||
@@ -160,6 +160,34 @@ The confirmed range is used as is, with no extra padding. Added lines go into th
|
|||||||
|
|
||||||
When you cancel, you can go back to editing, keep the original timing, create the card without media, or discard it. Discard deletes the Yomitan note or audio card, and skips creation for a sentence card.
|
When you cancel, you can go back to editing, keep the original timing, create the card without media, or discard it. Discard deletes the Yomitan note or audio card, and skips creation for a sentence card.
|
||||||
|
|
||||||
|
#### With a controller
|
||||||
|
|
||||||
|
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut once SubMiner has seen a controller, which takes one button press after connecting it. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
|
||||||
|
|
||||||
|
`A`, `B`, `X` and `Y` mean the letters printed on your controller. Pick your controller's **Button Style** in `Alt+C`: choose Nintendo if A is on the right and B at the bottom (Switch-style and many 8BitDo pads). On PlayStation, `A` is ╳, `B` ○, `X` □ and `Y` △, and the shortcuts on screen show those symbols.
|
||||||
|
|
||||||
|
Hold `L1` or `R1` and press the D-pad for the combos. `L1` covers the clip start and the lines before it, and `R1` the clip end and the lines after it. D-pad actions repeat while held.
|
||||||
|
|
||||||
|
This is the default layout:
|
||||||
|
|
||||||
|
| Controller | Editing | After Cancel |
|
||||||
|
| ----------------------- | -------------------------------- | ------------------- |
|
||||||
|
| `A` | Use this timing | Use original timing |
|
||||||
|
| `B` | Cancel | Keep editing |
|
||||||
|
| `X` | Play selection | Without media |
|
||||||
|
| `Y` | Reset timing | Discard |
|
||||||
|
| D-pad left / right | Earlier −2s / Later +2s | |
|
||||||
|
| D-pad up / down | Previous / next screenshot frame | |
|
||||||
|
| `L1` + D-pad left/right | Start −0.1s / +0.1s | |
|
||||||
|
| `L1` + D-pad up/down | Add / remove a previous line | |
|
||||||
|
| `R1` + D-pad left/right | End −0.1s / +0.1s | |
|
||||||
|
| `R1` + D-pad up/down | Add / remove a next line | |
|
||||||
|
| `L1` + `R1` | Reset the screenshot frame | |
|
||||||
|
|
||||||
|
To change it, open `Alt+C` and expand **Review media timing**. Click an action's pencil, then press a button, or hold one button and press another to make a combo; the first button becomes the one you hold. Any button can be a hold button. Changes are saved per controller, and **Reset all** brings back the default layout.
|
||||||
|
|
||||||
|
Nudges, Earlier/Later, lines and frames repeat while held whichever buttons they are on. A row warns you when two actions on the same step share an input, or when an action uses a button alone that other actions hold.
|
||||||
|
|
||||||
### Update behavior
|
### Update behavior
|
||||||
|
|
||||||
| Key | What it does |
|
| Key | What it does |
|
||||||
@@ -237,6 +265,21 @@ The manual dialog cancels itself after 90 seconds. Identical entries are not ded
|
|||||||
| `Backspace` | Back from the merge preview |
|
| `Backspace` | Back from the merge preview |
|
||||||
| `Esc` | Cancel and leave both cards unchanged |
|
| `Esc` | Cancel and leave both cards unchanged |
|
||||||
|
|
||||||
|
### With a controller {#field-grouping-with-a-controller}
|
||||||
|
|
||||||
|
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut once SubMiner has seen a controller. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
|
||||||
|
|
||||||
|
| Controller | Choosing the card | Merge preview |
|
||||||
|
| ------------------ | --------------------------------- | ----------------------- |
|
||||||
|
| D-pad left / right | Original card / New card | |
|
||||||
|
| D-pad up / down | | Scroll (repeats) |
|
||||||
|
| `A` | Continue | Confirm merge |
|
||||||
|
| `B` | Cancel | Back to the card choice |
|
||||||
|
| `X` | Turn "Delete duplicate" on or off | |
|
||||||
|
| `Y` | | Compact / Full |
|
||||||
|
|
||||||
|
To cancel from the preview, press `B` twice, or bind the preview's **Cancel** row (unbound by default). Change any of it in the **Duplicate card (field grouping)** group of `Alt+C`, the same way as the review layout.
|
||||||
|
|
||||||
## Config validation
|
## Config validation
|
||||||
|
|
||||||
Invalid `ankiConnect` values produce a warning and fall back to the default. Use JSON booleans (`true`, not `"true"`) and a positive number for `pollingRate`.
|
Invalid `ankiConnect` values produce a warning and fall back to the default. Use JSON booleans (`true`, not `"true"`) and a positive number for `pollingRate`.
|
||||||
@@ -24,6 +24,7 @@ src/
|
|||||||
preload*.ts # preload bridges (overlay, settings, stats, sync, Jellyfin setup)
|
preload*.ts # preload bridges (overlay, settings, stats, sync, Jellyfin setup)
|
||||||
main/ # main-process runtime modules and IPC/CLI wiring
|
main/ # main-process runtime modules and IPC/CLI wiring
|
||||||
boot/ # pre-ready boot helpers
|
boot/ # pre-ready boot helpers
|
||||||
|
assemblies/ # per-domain assemblies main.ts builds once (startup, overlay, playback, mining, stats, Yomitan, shortcuts, session actions, plugins, subtitle generation, tray, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals)
|
||||||
runtime/composers/ # larger runtime clusters assembled for main.ts
|
runtime/composers/ # larger runtime clusters assembled for main.ts
|
||||||
runtime/domains/ # domain barrels (startup, overlay, mpv, ipc, shortcuts, anilist, jellyfin, mining)
|
runtime/domains/ # domain barrels (startup, overlay, mpv, ipc, shortcuts, anilist, jellyfin, mining)
|
||||||
core/services/ # focused services: mpv client, overlay, tokenizer, mining, integrations, stats
|
core/services/ # focused services: mpv client, overlay, tokenizer, mining, integrations, stats
|
||||||
@@ -138,7 +139,7 @@ Runtime code uses dependency injection:
|
|||||||
2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused.
|
2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused.
|
||||||
3. Call the service from lifecycle or command wiring.
|
3. Call the service from lifecycle or command wiring.
|
||||||
|
|
||||||
`main.ts` gets domain handlers through `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`), which exposes the barrels in `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. Many handlers take a `*MainDeps` object built by a `createBuild*MainDepsHandler` builder, which keeps side effects out of the unit under test.
|
Domain handlers live in the barrels under `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as app startup, the overlay windows, mpv playback and subtitle state, card mining, stats, plugins, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. An assembly that answers renderer IPC or CLI commands also returns typed `ipc`/`cli` slices, and `main.ts` registers the handlers by combining those slices. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test.
|
||||||
|
|
||||||
Composers declare their inputs with `ComposerInputs<T>` and results with `ComposerOutputs<T>` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time.
|
Composers declare their inputs with `ComposerInputs<T>` and results with `ComposerOutputs<T>` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time.
|
||||||
|
|
||||||
|
|||||||
+58
-3
@@ -1,6 +1,61 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.21.1 (2026-10-26)
|
## v0.22.0 (2026-11-06)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- **Plugins**:
|
||||||
|
- JavaScript plugins load at startup from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
|
||||||
|
- Plugins can react to playback and mined cards, add bindable actions, run programs, and show results in overlay panels or their own windows.
|
||||||
|
- Example plugins cover AI line explanations (`ai-explain`), AnkiWeb sync (`anki-sync`), and show and episode tags on mined cards (`episode-tags`).
|
||||||
|
- Plugin authors get a versioned API with standalone types.
|
||||||
|
- **Subtitle Filters**: Remove text in parentheses, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
|
||||||
|
- **Controller Combos**: Controller actions can now be combos, such as holding `L1` and pressing D-pad right. Click **Learn** in `Alt+C`, then hold one button and press another.
|
||||||
|
- **Controller Actions**: New bindings cover previous, next, replay, and play-next subtitle, hold-to-show secondary subtitle, and previous or next Yomitan entry. All are unbound by default and can be assigned in `Alt+C`.
|
||||||
|
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be used with a controller and show button hints while one is connected. Remap both layouts per controller in `Alt+C`.
|
||||||
|
- **Controller Button Style**: Choose Xbox, Nintendo, or PlayStation button names per controller in `Alt+C`, or set a default with `controller.faceButtonLabels`.
|
||||||
|
- **Controller Clash Warnings**: `Alt+C` now warns when two actions share the same input.
|
||||||
|
- **Keyboard-Only Mode**:
|
||||||
|
- Press `C` or use the new controller binding to move by character, reaching words split by the subtitle parser.
|
||||||
|
- The new **Pause Video In Keyboard-Only Mode** setting pauses on entry and resumes on exit.
|
||||||
|
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Reopening Jimaku after the video changes now refreshes the title and episode.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- **Stats Trends and Vocabulary**:
|
||||||
|
- Trends adds a learning section, heatmap, title timeline, custom date ranges, and a page-wide title filter.
|
||||||
|
- Vocabulary now has one searchable word table, known words by frequency band, a growth chart, and a full kanji view.
|
||||||
|
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
|
||||||
|
- Stats now explains when streamed words or lines can't be mined.
|
||||||
|
- **Bundled Hachidori**: Updated to upstream 0.4.0, adding every pitch accent in popup entries, per-kanji headword furigana, and Yomitan-style definition tags.
|
||||||
|
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Connected and disabled messages now follow your `ankiConnect.behavior.notificationType` setting.
|
||||||
|
- **Dictionary Popup**: Turning keyboard-only mode off now also closes the popup.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **Controller Settings (`Alt+C`)**:
|
||||||
|
- Remaps now apply right away, and Learn works on **(D-pad)** rows.
|
||||||
|
- Clicking a row shows **Learn**, **Clear**, and **Reset** again.
|
||||||
|
- The window previews your bindings until a press, then selects the pressed controller.
|
||||||
|
- Button names are now correct on standard-layout controllers.
|
||||||
|
- **Controller Input on macOS and Windows**: One press no longer runs its action twice. On macOS, buttons now work while mpv is in front.
|
||||||
|
- **Dictionary Popup**: Keyboard and controller lookups now pause and resume playback reliably. Moving between words right after closing a popup no longer reopens it.
|
||||||
|
- **Playback**: Reloading the current file no longer leaves playback paused. The "Overlay loading" message no longer sticks or turns into a false error when a playlist advances.
|
||||||
|
- **Stats**:
|
||||||
|
- The dashboard, Vocabulary tab, and sentence search load much faster on large databases, and Trends downloads far less data.
|
||||||
|
- Trends now counts each finished episode once, and its Library totals match the selected range.
|
||||||
|
- Word totals, readings, Hide Names, and rate charts are now accurate.
|
||||||
|
- Date handling is now correct in time zones ahead of UTC.
|
||||||
|
- **AniList**: Progress sync now adds a missing show to Watching, or Completed on the final episode, when the title matches exactly or is pinned. Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
|
||||||
|
- **Anki Sentence Audio**: Generated sentence audio no longer overwrites Yomitan's word audio on default configs. If you relied on the old default, set `ankiConnect.fields.audio` to `ExpressionAudio`.
|
||||||
|
- **AppImage**: Fixed "Service Crash" notifications on quit on Ubuntu 26.04 and newer.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
|
||||||
|
|
||||||
|
## Previous Versions
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.21.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.21.1 (2026-10-26)</h2>
|
||||||
|
|
||||||
**Changed**
|
**Changed**
|
||||||
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||||
@@ -23,7 +78,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## v0.21.0 (2026-10-24)
|
<h2>v0.21.0 (2026-10-24)</h2>
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
- **Hachidori Dictionary Backend**:
|
- **Hachidori Dictionary Backend**:
|
||||||
@@ -68,7 +123,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Previous Versions
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>v0.20.x</summary>
|
<summary>v0.20.x</summary>
|
||||||
|
|||||||
+94
-55
@@ -52,7 +52,7 @@ SubMiner watches the config file while running. When it changes, live settings a
|
|||||||
|
|
||||||
These apply live:
|
These apply live:
|
||||||
|
|
||||||
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `keybindings`, `shortcuts`
|
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `subtitleFilter`, `keybindings`, `shortcuts`
|
||||||
- `logging.level`, `logging.rotation`, `logging.files`
|
- `logging.level`, `logging.rotation`, `logging.files`
|
||||||
- `secondarySub.defaultMode`, `youtube.primarySubLanguages`, `youtube.subtitleSource`
|
- `secondarySub.defaultMode`, `youtube.primarySubLanguages`, `youtube.subtitleSource`
|
||||||
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`, `stats.toggleKey`, `stats.markWatchedKey`
|
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`, `stats.toggleKey`, `stats.markWatchedKey`
|
||||||
@@ -106,6 +106,15 @@ Overlay notifications are also kept in a session-only history panel. Toggle it w
|
|||||||
|
|
||||||
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
|
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
|
||||||
|
|
||||||
|
### Plugins
|
||||||
|
|
||||||
|
`plugins` enables [plugins](/plugins) from the `plugins/` folder next to this config file. Each key is a plugin folder name. Changes apply after a restart.
|
||||||
|
|
||||||
|
| Key | Default | What it does |
|
||||||
|
| ----------------------- | ------- | -------------------------------------- |
|
||||||
|
| `plugins.<id>.enabled` | `false` | Load the plugin at startup |
|
||||||
|
| `plugins.<id>.settings` | `{}` | Passed to the plugin as `ctx.settings` |
|
||||||
|
|
||||||
### Auto-start overlay
|
### Auto-start overlay
|
||||||
|
|
||||||
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
|
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
|
||||||
@@ -166,30 +175,31 @@ Controls how primary and secondary subtitles look and which annotations they sho
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Key | Default | What it does |
|
| Key | Default | What it does |
|
||||||
| ------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `subtitleStyle.primaryDefaultMode` | `"visible"` | Primary bar at startup: `hidden`, `visible`, or `hover` |
|
| `subtitleStyle.primaryDefaultMode` | `"visible"` | Primary bar at startup: `hidden`, `visible`, or `hover` |
|
||||||
| `subtitleStyle.css` | see example | CSS for primary subtitles (font, size `35px`, color, shadow, and so on) |
|
| `subtitleStyle.css` | see example | CSS for primary subtitles (font, size `35px`, color, shadow, and so on) |
|
||||||
| `subtitleStyle.secondary.css` | see example | CSS for secondary subtitles (size `24px`) |
|
| `subtitleStyle.secondary.css` | see example | CSS for secondary subtitles (size `24px`) |
|
||||||
| `subtitleStyle.preserveLineBreaks` | `false` | Keep line breaks as mpv shows them instead of one line |
|
| `subtitleStyle.preserveLineBreaks` | `false` | Keep line breaks as mpv shows them instead of one line |
|
||||||
| `subtitleStyle.autoPauseVideoOnHover` | `true` | Pause while the mouse is over subtitle text |
|
| `subtitleStyle.autoPauseVideoOnHover` | `true` | Pause while the mouse is over subtitle text |
|
||||||
| `subtitleStyle.autoPauseVideoOnYomitanPopup` | `true` | Pause while a Yomitan popup is open |
|
| `subtitleStyle.autoPauseVideoOnYomitanPopup` | `true` | Pause while a Yomitan popup is open |
|
||||||
| `subtitleStyle.primaryVisibleOnYomitanPopup` | `true` | In hover mode, keep the primary bar visible while a popup is open |
|
| `subtitleStyle.autoPauseVideoOnKeyboardMode` | `false` | Pause while keyboard-only mode is on (keyboard or controller toggle). Leaving resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, stays as it is |
|
||||||
| `subtitleStyle.knownWordColor` | `#a6da95` | Known-word highlight color |
|
| `subtitleStyle.primaryVisibleOnYomitanPopup` | `true` | In hover mode, keep the primary bar visible while a popup is open |
|
||||||
| `subtitleStyle.knownWordMaturityColors` | see example | `new`, `learning`, `young`, `mature` colors, used when `ankiConnect.knownWords.maturityEnabled` is on |
|
| `subtitleStyle.knownWordColor` | `#a6da95` | Known-word highlight color |
|
||||||
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | N+1 target word color |
|
| `subtitleStyle.knownWordMaturityColors` | see example | `new`, `learning`, `young`, `mature` colors, used when `ankiConnect.knownWords.maturityEnabled` is on |
|
||||||
| `subtitleStyle.enableJlpt` | `false` | Underline words by JLPT level |
|
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | N+1 target word color |
|
||||||
| `subtitleStyle.jlptColors` | see example | Underline colors for `N1` to `N5` |
|
| `subtitleStyle.enableJlpt` | `false` | Underline words by JLPT level |
|
||||||
| `subtitleStyle.nameMatchEnabled` | `false` | Sync the character dictionary and color character names |
|
| `subtitleStyle.jlptColors` | see example | Underline colors for `N1` to `N5` |
|
||||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small character portraits next to matched names |
|
| `subtitleStyle.nameMatchEnabled` | `false` | Sync the character dictionary and color character names |
|
||||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Character-name color |
|
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small character portraits next to matched names |
|
||||||
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Color words by frequency rank |
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Character-name color |
|
||||||
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Folder with `term_meta_bank_*.json` files. Empty searches the default locations |
|
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Color words by frequency rank |
|
||||||
| `subtitleStyle.frequencyDictionary.topX` | `10000` | Only color words ranked at or below this |
|
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Folder with `term_meta_bank_*.json` files. Empty searches the default locations |
|
||||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `single` (one color) or `banded` (five colors, common to rare) |
|
| `subtitleStyle.frequencyDictionary.topX` | `10000` | Only color words ranked at or below this |
|
||||||
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | Look up by `headword` (dictionary form) or `surface` (text as shown) |
|
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `single` (one color) or `banded` (five colors, common to rare) |
|
||||||
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for `single` mode |
|
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | Look up by `headword` (dictionary form) or `surface` (text as shown) |
|
||||||
| `subtitleStyle.frequencyDictionary.bandedColors` | see example | Five colors for `banded` mode |
|
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for `single` mode |
|
||||||
|
| `subtitleStyle.frequencyDictionary.bandedColors` | see example | Five colors for `banded` mode |
|
||||||
|
|
||||||
Two CSS custom properties style the hovered word: `--subtitle-hover-token-color` (`#f4dbd6`) and `--subtitle-hover-token-background-color` (`transparent`). Set them inside `subtitleStyle.css`.
|
Two CSS custom properties style the hovered word: `--subtitle-hover-token-color` (`#f4dbd6`) and `--subtitle-hover-token-background-color` (`transparent`). Set them inside `subtitleStyle.css`.
|
||||||
|
|
||||||
@@ -211,6 +221,29 @@ Sidebar custom properties: `--subtitle-sidebar-max-width` (`420px`), `--subtitle
|
|||||||
|
|
||||||
If `embedded` layout places the video oddly on your system, switch back to `overlay`.
|
If `embedded` layout places the video oddly on your system, switch back to `overlay`.
|
||||||
|
|
||||||
|
### Subtitle filters {#subtitle-filters}
|
||||||
|
|
||||||
|
Removes unwanted text from primary subtitles, such as speaker names, readings and sound descriptions. Filtering happens as the line arrives, so the overlay, sidebar, mined sentences and stats all see the filtered text. A line left with no words is hidden. In the settings window, find it under **Behavior → Subtitle Filters**.
|
||||||
|
|
||||||
|
| Key | Default | What it does |
|
||||||
|
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `subtitleFilter.presets.parentheses` | `false` | Remove `(…)` and `(…)` anywhere in a line. A line wrapped entirely in parentheses (inner thoughts) is kept |
|
||||||
|
| `subtitleFilter.presets.brackets` | `false` | Remove `[…]`, `[…]`, `【…】` and `〔…〕`, such as sound effects |
|
||||||
|
| `subtitleFilter.presets.speakerPrefix` | `false` | Remove a short name before a colon at the start of a line, as in `山田:` |
|
||||||
|
| `subtitleFilter.presets.musicSymbols` | `false` | Remove `♪`, `♫` and `♬` |
|
||||||
|
| `subtitleFilter.customRules` | `[]` | Your own regex rules, applied in order after the presets |
|
||||||
|
|
||||||
|
Each custom rule is `{ "name", "pattern", "replacement", "enabled" }`. Patterns are JavaScript regular expressions compiled with the `gmu` flags, so `^` and `$` match each line. An empty `replacement` removes the match.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"subtitleFilter": {
|
||||||
|
"presets": { "parentheses": true },
|
||||||
|
"customRules": [{ "name": "Join lines", "pattern": "\\n", "replacement": "", "enabled": true }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Subtitle position
|
### Subtitle position
|
||||||
|
|
||||||
You can also drag subtitles with `Right-click + drag` while watching.
|
You can also drag subtitles with `Right-click + drag` while watching.
|
||||||
@@ -268,7 +301,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
|
|||||||
|
|
||||||
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
|
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
|
||||||
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
|
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
|
||||||
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, and `__runtime-option-cycle:<id>[:next|prev]`.
|
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, `__runtime-option-cycle:<id>[:next|prev]`, and `__plugin-action:<plugin id>/<action>` (see [Plugins](/plugins)).
|
||||||
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
|
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
|
||||||
|
|
||||||
### Shortcuts configuration
|
### Shortcuts configuration
|
||||||
@@ -281,21 +314,26 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
|
|||||||
|
|
||||||
### Controller support
|
### Controller support
|
||||||
|
|
||||||
Gamepad input for the overlay, through the browser Gamepad API. It only works while keyboard-only mode is on. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
|
Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode, the secondary subtitle hold (`controller.bindings.holdSecondarySub`), pause (`toggleMpvPause`) and the subtitle line actions (`previousSubtitle`, `nextSubtitle`, `replaySubtitle`, `playNextSubtitle`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are in [Controller support](/usage#controller-support).
|
||||||
|
|
||||||
| Key | Default | What it does |
|
| Key | Default | What it does |
|
||||||
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------- |
|
| ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off |
|
| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off |
|
||||||
| `controller.smoothScroll` | `true` | Smooth popup scrolling |
|
| `controller.smoothScroll` | `true` | Smooth popup scrolling |
|
||||||
| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed |
|
| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed |
|
||||||
| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance |
|
| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance |
|
||||||
| `controller.stickDeadzone` | `0.2` | Stick deadzone |
|
| `controller.stickDeadzone` | `0.2` | Stick deadzone |
|
||||||
| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values |
|
| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values |
|
||||||
| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` |
|
| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` |
|
||||||
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
|
| `controller.faceButtonLabels` | `"xbox"` | Face button style: `xbox`, `nintendo` (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or `playstation` (╳ ○ □ △). **Button Style** in `Alt+C` sets it per controller; this is the default for the others |
|
||||||
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
|
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
|
||||||
|
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
|
||||||
|
|
||||||
Bindings are set with `Alt+C` learn mode, which saves them per controller.
|
Bindings, including the media timing review and duplicate card dialog layouts, are set with `Alt+C` learn mode, which saves them per controller. Learn can also record a [combo](/usage#controller-support), saved on the binding as an optional `hold` input in the same form:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"nextAudio": { "kind": "button", "buttonIndex": 15, "hold": { "kind": "button", "buttonIndex": 4 } }
|
||||||
|
```
|
||||||
|
|
||||||
## Anki integration
|
## Anki integration
|
||||||
|
|
||||||
@@ -329,15 +367,15 @@ Creates and updates Anki cards with sentence, audio, and screenshot. Needs the [
|
|||||||
|
|
||||||
**Fields**
|
**Fields**
|
||||||
|
|
||||||
| Key | Default | What it does |
|
| Key | Default | What it does |
|
||||||
| ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `ankiConnect.fields.word` | `"Expression"` | Word field |
|
| `ankiConnect.fields.word` | `"Expression"` | Word field |
|
||||||
| `ankiConnect.fields.audio` | `"ExpressionAudio"` | Field that receives sentence audio. Set a separate field such as `SentenceAudio` so it does not overwrite Yomitan's word audio |
|
| `ankiConnect.fields.audio` | `"SentenceAudio"` | Field that receives sentence audio. Keep it separate from Yomitan's word-audio field so it does not overwrite that audio |
|
||||||
| `ankiConnect.fields.wordAudio` | `"ExpressionAudio"` | Existing word-audio field, read only to time animated images |
|
| `ankiConnect.fields.wordAudio` | `"ExpressionAudio"` | Existing word-audio field, read only to time animated images |
|
||||||
| `ankiConnect.fields.image` | `"Picture"` | Screenshot field |
|
| `ankiConnect.fields.image` | `"Picture"` | Screenshot field |
|
||||||
| `ankiConnect.fields.sentence` | `"Sentence"` | Sentence field |
|
| `ankiConnect.fields.sentence` | `"Sentence"` | Sentence field |
|
||||||
| `ankiConnect.fields.miscInfo` | `"MiscInfo"` | Metadata field. `null` disables |
|
| `ankiConnect.fields.miscInfo` | `"MiscInfo"` | Metadata field. `null` disables |
|
||||||
| `ankiConnect.metadata.pattern` | `"[SubMiner] %f (%t)"` | MiscInfo template: `%f` filename, `%F` filename with extension, `%t` time, `%T` time with ms, `<br>` newline |
|
| `ankiConnect.metadata.pattern` | `"[SubMiner] %f (%t)"` | MiscInfo template: `%f` filename, `%F` filename with extension, `%t` time, `%T` time with ms, `<br>` newline |
|
||||||
|
|
||||||
**Media**
|
**Media**
|
||||||
|
|
||||||
@@ -437,13 +475,14 @@ When SubMiner fills the sentence on a word card, it sets one card-type flag and
|
|||||||
|
|
||||||
Search and download Japanese subtitles from [Jimaku](https://jimaku.cc). See [Jimaku integration](/jimaku-integration).
|
Search and download Japanese subtitles from [Jimaku](https://jimaku.cc). See [Jimaku integration](/jimaku-integration).
|
||||||
|
|
||||||
| Key | Default | What it does |
|
| Key | Default | What it does |
|
||||||
| --------------------------- | --------------------- | ---------------------------------------------------------- |
|
| ------------------------------------------- | --------------------- | --------------------------------------------------------------------------------- |
|
||||||
| `jimaku.apiKey` | `""` | API key. Optional, but raises your rate limit |
|
| `jimaku.apiKey` | `""` | API key. Optional, but raises your rate limit |
|
||||||
| `jimaku.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` |
|
| `jimaku.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` |
|
||||||
| `jimaku.apiBaseUrl` | `"https://jimaku.cc"` | API base URL |
|
| `jimaku.apiBaseUrl` | `"https://jimaku.cc"` | API base URL |
|
||||||
| `jimaku.languagePreference` | `"ja"` | Preferred language: `ja`, `en`, or `none` |
|
| `jimaku.languagePreference` | `"ja"` | Preferred language: `ja`, `en`, or `none` |
|
||||||
| `jimaku.maxEntryResults` | `10` | Maximum search results |
|
| `jimaku.maxEntryResults` | `10` | Maximum search results |
|
||||||
|
| `jimaku.autoOpenWhenPrimarySubtitleMissing` | `false` | Pause and open the Jimaku modal when a local video has no Japanese subtitle track |
|
||||||
|
|
||||||
### TsukiHime
|
### TsukiHime
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,17 @@ The same show can end up on several cards when release names disagree. To fix th
|
|||||||
|
|
||||||
### Trends
|
### Trends
|
||||||
|
|
||||||
Charts for watch time, cards, words, and sessions per day or month, running totals, efficiency (words per minute, cards per hour), and viewing patterns by weekday and hour. Each chart has its own date range and grouping.
|
Pick a date range (or set **Custom** dates), group by day, week, or month, and the whole page follows. Your choices are remembered.
|
||||||
|
|
||||||
|
- **Summary**: totals for the range, compared with the period just before it.
|
||||||
|
- **Activity**: watch time, cards, words, and finished episodes. Click a bar to open that period's sessions.
|
||||||
|
- **Learning**: new words per 1,000 words seen, and how common those new words are. Conjugations of a word count once, and names are left out.
|
||||||
|
- **Efficiency**: words per minute, cards per hour, and lookups per 100 words. Daily views use a rolling 7 days, and gaps mean too little data to rate.
|
||||||
|
- **Running total**: one chart with a switcher for each metric, climbing from zero at the start of the range.
|
||||||
|
- **Patterns**: a day and hour heatmap of when you watch.
|
||||||
|
- **Titles**: a timeline of what you watched when, and a sortable summary table. Click a title, or use the filter box, to narrow the page to it. **↗** opens the title.
|
||||||
|
|
||||||
|
An episode counts as finished once, on the last day you watched it.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -76,10 +86,15 @@ Session history. Expand a session for its pace, lines, known-word rate, lookups,
|
|||||||
|
|
||||||
### Vocabulary
|
### Vocabulary
|
||||||
|
|
||||||
Unique words and kanji you have seen, new words per day, frequency rank tables with Hide Known and Hide Kana filters, and a kanji breakdown. Click a word to see every line it appeared in.
|
Words are counted as dictionary words, so a word's conjugations count once. Names are left out unless you turn off **Hide names**.
|
||||||
|
|
||||||
- **Exclusions** hides words from every vocabulary view. You can restore them from the same dialog.
|
- **Summary**: words seen, how many are in Anki, new words this week, and kanji seen.
|
||||||
- **Duplicates** cleans up lines repeated by karaoke openings and animated signs (see [Repeated lines](#repeated-lines)).
|
- **Known Words by Frequency**: how much of each frequency band you already know.
|
||||||
|
- **Vocabulary Growth**: your running total of words seen.
|
||||||
|
- **Words**: one table for every word you have seen. **Worth mining** lists common words you haven't mined, most seen first. **All words**, **Across titles**, and **Newest** are the other views. Sort by any column, or filter by known words, names, kana, and part of speech. Searching looks through everything, including conjugated forms and readings. Click a word for its forms, where it appeared, and example lines you can mine from. **Hide** excludes a word.
|
||||||
|
- **Kanji**: every kanji you have seen, shaded by how often. Dashed kanji are not in any word you know.
|
||||||
|
|
||||||
|
**Exclusions** at the bottom of the page lists hidden words so you can restore them. **Clean up duplicates** removes lines repeated by karaoke openings and animated signs (see [Repeated lines](#repeated-lines)).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -89,7 +104,7 @@ Searches the primary subtitle lines and titles in your history. **Search by head
|
|||||||
|
|
||||||
## Mining from the dashboard
|
## Mining from the dashboard
|
||||||
|
|
||||||
Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available:
|
Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available. Lines from a stream, such as Jellyfin or YouTube, have no file to cut clips from, so they say so instead of offering mining. Lines you can mine are listed first. The word panel counts how many of a word's lines this affects, and the word table marks words with only streamed lines as **Stream only**.
|
||||||
|
|
||||||
- **Mine Word**: looks up the word with the selected dictionary backend (Yomitan or Hachidori), plus sentence, audio, and image. The history line you picked is the card's sentence, even while mpv plays another line. Hachidori uses its own Anki template, dictionary aliases, and frequency data.
|
- **Mine Word**: looks up the word with the selected dictionary backend (Yomitan or Hachidori), plus sentence, audio, and image. The history line you picked is the card's sentence, even while mpv plays another line. Hachidori uses its own Anki template, dictionary aliases, and frequency data.
|
||||||
- **Mine Sentence**: a sentence card with `IsSentenceCard` set, for Lapis and Kiku note types.
|
- **Mine Sentence**: a sentence card with `IsSentenceCard` set, for Lapis and Kiku note types.
|
||||||
@@ -103,7 +118,7 @@ Dashboard cards also get the `SubMiner::Stats` tag. With the Anki proxy off, Sub
|
|||||||
|
|
||||||
Karaoke openings and animated signs repeat the same text once per frame. SubMiner collapses these as it records, so one lyric is stored once. Stats recorded before that can hold hundreds of copies and skew Top Repeated Words.
|
Karaoke openings and animated signs repeat the same text once per frame. SubMiner collapses these as it records, so one lyric is stored once. Stats recorded before that can hold hundreds of copies and skew Top Repeated Words.
|
||||||
|
|
||||||
To clean them, use **Duplicates** in the Vocabulary tab: pick a time window, **Scan** to preview, then **Clean Up**. Or from the terminal:
|
To clean them, use **Clean up duplicates** in the Vocabulary tab: pick a time window, **Scan** to preview, then **Clean Up**. Or from the terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer stats cleanup --duplicate-lines --dry-run --lookback-days 30
|
subminer stats cleanup --duplicate-lines --dry-run --lookback-days 30
|
||||||
|
|||||||
@@ -37,17 +37,32 @@ The file is saved next to the video (or to a temp directory for streams) and loa
|
|||||||
|
|
||||||
You can also open the modal with `subminer app --open-jimaku`, or change the shortcut with `shortcuts.openJimaku`.
|
You can also open the modal with `subminer app --open-jimaku`, or change the shortcut with `shortcuts.openJimaku`.
|
||||||
|
|
||||||
|
### Open automatically when a video has no Japanese subtitles
|
||||||
|
|
||||||
|
Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to `true` to skip the shortcut. When a local video loads without a subtitle track in `youtube.primarySubLanguages` (Japanese by default), SubMiner pauses playback about a second after the video loads and opens the Jimaku modal, so you can pick a subtitle before watching. Playback stays paused while the modal is open and resumes when it closes, whether or not you picked a subtitle. On a cold start it resumes once subtitle processing is ready, as usual, and a pause you made yourself before the modal opened is kept.
|
||||||
|
|
||||||
|
A single unlabeled sidecar file (such as `episode.srt` next to the video) counts as a Japanese track, matching how SubMiner auto-selects local subtitles. An embedded track with no language tag does not, so the modal still opens for it. Audio files, streams, YouTube, and Jellyfin playback are not checked.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"jimaku": {
|
||||||
|
"autoOpenWhenPrimarySubtitleMissing": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
The file name parser understands `S01E03`, `1x03`, `E03`, `EP03`, and `Title - 03 -` patterns, and reads the season from a parent folder such as `Season 2`. It ignores bracket tags like `[SubGroup]` and year tags like `(2024)`.
|
The file name parser understands `S01E03`, `1x03`, `E03`, `EP03`, and `Title - 03 -` patterns, and reads the season from a parent folder such as `Season 2`. It ignores bracket tags like `[SubGroup]` and year tags like `(2024)`.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Key | What it does |
|
| Key | What it does |
|
||||||
| --------------------------- | ------------------------------------------------------------------- |
|
| ------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| `jimaku.apiKey` | API key in plain text. |
|
| `jimaku.apiKey` | API key in plain text. |
|
||||||
| `jimaku.apiKeyCommand` | Shell command that prints the API key. |
|
| `jimaku.apiKeyCommand` | Shell command that prints the API key. |
|
||||||
| `jimaku.languagePreference` | Sorts files tagged with this language first: `ja`, `en`, or `none`. |
|
| `jimaku.languagePreference` | Sorts files tagged with this language first: `ja`, `en`, or `none`. |
|
||||||
| `jimaku.maxEntryResults` | Maximum entries per search. |
|
| `jimaku.maxEntryResults` | Maximum entries per search. |
|
||||||
| `jimaku.apiBaseUrl` | API address. Change only for a mirror. |
|
| `jimaku.apiBaseUrl` | API address. Change only for a mirror. |
|
||||||
|
| `jimaku.autoOpenWhenPrimarySubtitleMissing` | Pauses and opens the modal when a local video has no Japanese track. |
|
||||||
|
|
||||||
See [Configuration](/configuration#jimaku) for defaults.
|
See [Configuration](/configuration#jimaku) for defaults.
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
|
|
||||||
`log-level` sets SubMiner's log verbosity. Do not use `--debug` for this; it turns on the app's dev mode.
|
`log-level` sets SubMiner's log verbosity. Do not use `--debug` for this; it turns on the app's dev mode.
|
||||||
|
|
||||||
The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv).
|
The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-overlay-loading-dismissed`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv).
|
||||||
|
|
||||||
## Auto-start behavior
|
## Auto-start behavior
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
Plugins are JavaScript files that SubMiner loads at startup. A plugin can react to playback, run code on a timer, add actions you bind to keys, run programs, and show results in an overlay panel, all without rebuilding SubMiner.
|
||||||
|
|
||||||
|
::: warning Only run plugins you trust
|
||||||
|
Plugins run inside SubMiner with the same access to your files and system as SubMiner itself. There is no sandbox.
|
||||||
|
:::
|
||||||
|
|
||||||
|
These are SubMiner app plugins. The Lua script that runs inside mpv is the separate [mpv plugin](/mpv-plugin).
|
||||||
|
|
||||||
|
## Install a plugin
|
||||||
|
|
||||||
|
1. Put the plugin in its own folder in `plugins/` next to your [config file](/configuration#configuration-file), for example `~/.config/SubMiner/plugins/ai-explain/`. The folder name is the plugin id.
|
||||||
|
2. Enable it in `config.jsonc`. `settings` is passed to the plugin as-is:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"ai-explain": { "enabled": true, "settings": { "reasoningEffort": "medium" } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Bind its actions to keys with the `__plugin-action:<plugin id>/<action>` command:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"keybindings": [
|
||||||
|
{ "key": "Ctrl+KeyE", "command": ["__plugin-action:ai-explain/explain"] },
|
||||||
|
{ "key": "Ctrl+KeyT", "command": ["__plugin-action:ai-explain/translate"] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To run an action from a script or a window-manager shortcut, use `subminer app --plugin-action ai-explain/explain`. It goes to the running SubMiner, or starts one if none is running.
|
||||||
|
|
||||||
|
4. Restart SubMiner. Plugin changes apply on the next start; keybinding changes apply immediately.
|
||||||
|
|
||||||
|
Load errors and plugin log output go to the app log with a `plugin:<id>` scope.
|
||||||
|
|
||||||
|
## Example: explain the current line with Codex
|
||||||
|
|
||||||
|
The repository ships [`assets/plugins/ai-explain`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/ai-explain). It sends the current line, plus the lines around it, to [`codex exec`](https://github.com/openai/codex) and shows the answer in a panel. It needs a logged-in Codex CLI.
|
||||||
|
|
||||||
|
| Action | What it does |
|
||||||
|
| ----------- | --------------------------------------------------------------- |
|
||||||
|
| `explain` | Translation plus a breakdown of the grammar and vocabulary |
|
||||||
|
| `translate` | Natural English translation, with a note only when it's unclear |
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
| ----------------- | --------- | ----------------------------------------------------------------- |
|
||||||
|
| `codexPath` | `"codex"` | Codex binary. Use a full path if SubMiner can't find it on `PATH` |
|
||||||
|
| `model` | `""` | Model passed to `codex exec -m`; empty uses your Codex config |
|
||||||
|
| `reasoningEffort` | `"low"` | Codex reasoning effort; empty uses your Codex config |
|
||||||
|
| `before`, `after` | `2`, `1` | Lines of context sent before and after the current line |
|
||||||
|
| `pause` | `true` | Pause playback when the panel opens |
|
||||||
|
| `timeoutMs` | `120000` | Give up after this long |
|
||||||
|
| `prompts` | `{}` | Replace an action's instructions, e.g. `{ "translate": "..." }` |
|
||||||
|
|
||||||
|
Press `Esc` to close the panel. Closing it, or starting another request, stops the running Codex process.
|
||||||
|
|
||||||
|
## Example: sync Anki after mining
|
||||||
|
|
||||||
|
[`assets/plugins/anki-sync`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/anki-sync) runs AnkiConnect's `sync` after you mine cards, so they reach AnkiWeb without opening Anki. It waits until you stop mining for a while, so a burst of cards becomes one sync. It also adds a `sync` action you can bind to a key.
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
| ----------- | ------- | ------------------------------------------------------- |
|
||||||
|
| `delayMs` | `30000` | Sync after no card has been mined for this long |
|
||||||
|
| `syncOnEnd` | `true` | Sync right away when playback ends or mpv quits |
|
||||||
|
| `timeoutMs` | `60000` | Give up on a sync after this long |
|
||||||
|
| `notify` | `true` | Show a notification after each sync. Failures always do |
|
||||||
|
|
||||||
|
Anki must be running and logged in to AnkiWeb. Cards mined from the stats dashboard don't trigger a sync.
|
||||||
|
|
||||||
|
## Example: tag cards with the episode
|
||||||
|
|
||||||
|
[`assets/plugins/episode-tags`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/episode-tags) tags each mined card with the show and episode it came from, such as `show::Sousou_no_Frieren::E07`. It reads them from the file name (`[Group] Show - 07 (1080p).mkv`, `Show.S01E07.mkv`, `Show 第7話.mkv`), or from the title mpv shows for streams. It uses the source captured when mining started, so delayed media and field grouping into an older note keep the correct episode. Notes with unknown provenance, including cards discovered by polling, are left alone. Spaces become `_`.
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
| -------- | ------------------------ | ----------------------------------------------- |
|
||||||
|
| `tags` | `["show::{show}::{ep}"]` | Tags to add, one template each |
|
||||||
|
| `notify` | `false` | Show a notification with the tags for each card |
|
||||||
|
|
||||||
|
Templates can use `{show}`, `{season}`, `{episode}`, and `{ep}` (`S01E07`, or `E07` when there is no season). When a placeholder has no value, the tag stops before that `::` level, so a movie gets `show::<title>`. The parsing is a guess from the name: a title ending in a number, like `Mob Psycho 100`, reads that number as the episode.
|
||||||
|
|
||||||
|
## Write a plugin
|
||||||
|
|
||||||
|
A plugin folder needs a `plugin.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "My Plugin", "version": "1.0.0", "apiVersion": 1, "main": "main.js" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`apiVersion` declares the required SubMiner plugin API version. The current version is `1`; omitted values also mean v1 for existing plugins. An unsupported version is rejected before the plugin's entry module runs. `version` is the plugin's own version and does not control compatibility.
|
||||||
|
|
||||||
|
`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. Activation may be asynchronous and has a 10-second timeout. A plugin's actions wait for its own activation, so a slow plugin does not hold up other plugins. If activation fails or times out, its registrations are removed and its signal aborts. Synchronous code still shares the app's main thread.
|
||||||
|
|
||||||
|
Activation may return a cleanup function, or the module can export `deactivate()`. On quit, SubMiner aborts `ctx.signal` first and waits up to 2 seconds in total for loading and deactivation. A plugin still reading its files is skipped. Everything a plugin registers through `ctx` is cleaned up automatically. A late activation cannot leave new listeners, timers, or actions behind, and its returned cleanup still runs once. Deactivation can call Anki, mpv, built-in actions, and notifications until disposal. After disposal, those calls throw or reject, including work that resumes after an activation or deactivation timeout.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function activate(ctx) {
|
||||||
|
ctx.media.on('change', (media) => {
|
||||||
|
if (media) ctx.log.info(`Now playing ${media.path}`);
|
||||||
|
});
|
||||||
|
ctx.every(60_000, () => ctx.log.info('Still watching'));
|
||||||
|
ctx.actions.register('hello', () => {
|
||||||
|
const line = ctx.subtitles.current();
|
||||||
|
ctx.ui.panel({ title: 'Hello', markdown: line ? `> ${line.text}` : '_No subtitle_' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { activate };
|
||||||
|
```
|
||||||
|
|
||||||
|
An error thrown by a plugin is logged and does not crash SubMiner. A plugin that blocks with a long synchronous loop still freezes the app, so keep slow work asynchronous.
|
||||||
|
|
||||||
|
### Types and cleanup
|
||||||
|
|
||||||
|
The public types live in [`src/plugin-api.ts`](https://github.com/ksyasuda/SubMiner/blob/main/src/plugin-api.ts). A build emits `dist/plugin-api.d.ts`; copy that file into your plugin as `subminer.d.ts`, or copy the source file with that name. It has no imports from SubMiner internals. Electron's types must be available to your editor because `ui.openWindow` returns a `BrowserWindow`.
|
||||||
|
|
||||||
|
JavaScript plugins can use the types through JSDoc:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/** @param {import('./subminer').PluginContext} ctx */
|
||||||
|
function activate(ctx) {
|
||||||
|
const { ipcMain } = require('electron');
|
||||||
|
ipcMain.handle(`${ctx.id}:info`, () => ({ media: ctx.media.current() }));
|
||||||
|
ctx.onDispose(() => ipcMain.removeHandler(`${ctx.id}:info`));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { activate };
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `ctx.signal` with `fetch` or other cancellable work. It aborts when unloading starts, before `deactivate` runs. `ctx.onDispose(fn)` registers synchronous cleanup for resources outside `ctx`, such as file watchers and Electron IPC handlers. The returned function runs the cleanup early, at most once. Registering cleanup after unloading starts runs it immediately. Timers and listeners registered after that point are removed immediately too. Use the returned activation cleanup or `deactivate` for asynchronous teardown, within the shared quit timeout.
|
||||||
|
|
||||||
|
### `ctx` reference
|
||||||
|
|
||||||
|
| Member | What it does |
|
||||||
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
|
||||||
|
| `apiVersion` | The host's plugin API version, currently `1` |
|
||||||
|
| `signal`, `onDispose(fn)` | Lifetime cancellation and cleanup for resources created outside `ctx` |
|
||||||
|
| `dataDir` | `plugin-data/<id>/` next to your config file, for caches and state. Exists before `activate` runs |
|
||||||
|
| `log.debug/info/warn/error(message)` | Write to the app log |
|
||||||
|
| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them |
|
||||||
|
| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function |
|
||||||
|
| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
|
||||||
|
| `mpv.request(args)` | Await an mpv command result; rejects on command error, disconnect, or timeout |
|
||||||
|
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
|
||||||
|
| `mpv.connected` | Whether mpv is connected |
|
||||||
|
| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails |
|
||||||
|
| `media.current()` | Cached `{ playbackId, path, title }`, or `null` when there is no connected media |
|
||||||
|
| `media.on('change', fn, options)` | Subscribe to playback snapshots; immediately delivers the current snapshot unless `emitCurrent: false` |
|
||||||
|
| `anki.on(event, fn)` | Listen to Anki events (below). Returns an unsubscribe function |
|
||||||
|
| `anki.invoke(action, params, options)` | Send an AnkiConnect action to `ankiConnect.url`, e.g. `('sync')`. Resolves its result (below) |
|
||||||
|
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
|
||||||
|
| `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams |
|
||||||
|
| `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` |
|
||||||
|
| `actions.run(actionId, payload)` | Run a built-in action, the same ids keybindings use, e.g. `openJimaku` or `mineSentence` |
|
||||||
|
| `notify(message, { variant })` | Overlay notification. `variant` is `info`, `warning`, or `error` |
|
||||||
|
| `ui.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` |
|
||||||
|
| `ui.openWindow({ html, preload })` | Open a window from the plugin folder (below). Returns the Electron `BrowserWindow` |
|
||||||
|
| `exec(command, args, options)` | Run a program without a shell (Windows `.cmd`/`.bat` shims go through `cmd.exe`). Resolves `{ exitCode, stdout, stderr }` |
|
||||||
|
| `unstable` | SubMiner internals. These change between releases without notice |
|
||||||
|
|
||||||
|
A panel's `signal` aborts when the user closes it or another panel replaces it. Pass it to `exec` to stop work nobody will see. Closing or replacing a panel also cancels pending open requests, including while its window loads. Only one panel is open at a time. Panel markdown supports headings, lists, quotes, code, bold, and italics; HTML is shown as plain text.
|
||||||
|
|
||||||
|
`exec` options are `stdin`, `cwd`, `env`, `timeoutMs`, and `signal`. It resolves for any exit code and rejects when the program can't start, times out, or is aborted.
|
||||||
|
|
||||||
|
`ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits.
|
||||||
|
|
||||||
|
`media` snapshots are immutable. `playbackId` changes on a new load, including reopening the same path. A title may arrive later with the same id. Disconnecting or ending playback delivers `null`. Use the id to discard stale asynchronous results. Snapshots describe cached playback state; they do not promise that external subtitle discovery has finished.
|
||||||
|
|
||||||
|
`mpv.request` accepts string, number, and boolean arguments, e.g. `await ctx.mpv.request(['set_property', 'pause', true])`. It returns mpv's result data and rejects after 4 seconds. A timeout does not cancel the command in mpv, so a slow command may still complete. `mpv.command` remains available for commands whose result you do not need.
|
||||||
|
|
||||||
|
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, `file-loaded`, and `end-file`. `file-loaded` has no payload. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
|
||||||
|
|
||||||
|
Anki events: `card-mined` gives `{ noteIds, notes }` once SubMiner has finished filling in a mined card (sentence, audio, image, and field grouping). For YouTube cards whose media waits on the download, it fires once that media is added or the download fails. With `ankiConnect.behavior.autoUpdateNewCards` off, it fires as soon as SubMiner sees the card. If field grouping merged the card into an older one, `noteIds` contains the older card's id.
|
||||||
|
|
||||||
|
Each `notes` entry has `{ originalNoteId, noteId, provenance }`. `originalNoteId` identifies the note before merges; `noteId` is the note that kept its content. `provenance` is `{ minedAt, media, subtitle }`, captured before asynchronous creation or the AnkiConnect proxy's upstream request. `minedAt` is Unix time in milliseconds. `media` uses the snapshot format above, and `subtitle` uses `{ text, startTime, endTime }`. Either may be `null`. Polling cannot establish the original source, so its provenance is `null` rather than the currently playing file.
|
||||||
|
|
||||||
|
`subtitles.current()` resolves parsed cues using the current subtitle delay and returns their times on the subtitle file's timeline.
|
||||||
|
|
||||||
|
Sentence-card provenance also uses the subtitle file's timeline. Multi-line mining converts the history range using the subtitle delay when mining starts; changing the delay between those lines can affect the reported range.
|
||||||
|
|
||||||
|
Several original notes can merge into one kept note. `notes` preserves each source, while `noteIds` contains unique kept ids. Existing listeners using only `noteIds` continue to work. Cards mined from the stats dashboard do not emit this event.
|
||||||
|
|
||||||
|
`anki.invoke` takes `{ timeoutMs }` (default 10 seconds), rejects on an Anki error, and never retries, so an action like `sync` or `addNote` runs at most once.
|
||||||
@@ -108,6 +108,7 @@
|
|||||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||||
|
"faceButtonLabels": "xbox", // Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others. Values: xbox | nintendo | playstation
|
||||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||||
"buttonIndices": {
|
"buttonIndices": {
|
||||||
@@ -127,38 +128,62 @@
|
|||||||
"toggleLookup": {
|
"toggleLookup": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"closeLookup": {
|
"closeLookup": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"toggleKeyboardOnlyMode": {
|
"toggleKeyboardOnlyMode": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"mineCard": {
|
"mineCard": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"quitMpv": {
|
"quitMpv": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"previousAudio": {
|
"previousAudio": {
|
||||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"nextAudio": {
|
"nextAudio": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"playCurrentAudio": {
|
"playCurrentAudio": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"toggleMpvPause": {
|
"toggleMpvPause": {
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
||||||
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"holdSecondarySub": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"previousEntry": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"nextEntry": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"toggleCharacterNavigation": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"previousSubtitle": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"nextSubtitle": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"replaySubtitle": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
|
"playNextSubtitle": {
|
||||||
|
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||||
|
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||||
"leftStickHorizontal": {
|
"leftStickHorizontal": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||||
@@ -169,17 +194,294 @@
|
|||||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
||||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
"rightStickHorizontal": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
"rightStickVertical": {
|
"rightStickVertical": {
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||||
|
"reviewBindings": {
|
||||||
|
"confirm": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Use this timing.
|
||||||
|
"cancel": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Cancel.
|
||||||
|
"play": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Play selection.
|
||||||
|
"resetTiming": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Reset timing.
|
||||||
|
"showEarlier": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Earlier −2s. Repeats while held.
|
||||||
|
"showLater": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Later +2s. Repeats while held.
|
||||||
|
"framePrevious": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Previous screenshot frame. Repeats while held.
|
||||||
|
"frameNext": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Next screenshot frame. Repeats while held.
|
||||||
|
"frameReset": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Reset screenshot frame.
|
||||||
|
"startBack": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Start −0.1s. Repeats while held.
|
||||||
|
"startForward": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Start +0.1s. Repeats while held.
|
||||||
|
"previousLineAdd": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Add previous line. Repeats while held.
|
||||||
|
"previousLineRemove": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 4 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Remove previous line. Repeats while held.
|
||||||
|
"endBack": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: End −0.1s. Repeats while held.
|
||||||
|
"endForward": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: End +0.1s. Repeats while held.
|
||||||
|
"nextLineAdd": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Add next line. Repeats while held.
|
||||||
|
"nextLineRemove": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": {
|
||||||
|
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"buttonIndex": 5 // Raw button index.
|
||||||
|
} // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Remove next line. Repeats while held.
|
||||||
|
"useOriginal": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Use original timing (after cancel).
|
||||||
|
"keepEditing": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Keep editing (after cancel).
|
||||||
|
"skipMedia": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Review dialog: Without media (after cancel).
|
||||||
|
"discard": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
} // Review dialog: Discard card (after cancel).
|
||||||
|
}, // Controller layout for the media timing review dialog, one entry per action. Set it with the Review media timing group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
|
||||||
|
"fieldGroupingBindings": {
|
||||||
|
"selectOriginal": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Original card.
|
||||||
|
"selectNew": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: New card.
|
||||||
|
"toggleDeleteDuplicate": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Delete duplicate on/off.
|
||||||
|
"continue": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Continue.
|
||||||
|
"cancel": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Cancel.
|
||||||
|
"confirmMerge": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Confirm merge (merge preview).
|
||||||
|
"back": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Back (merge preview).
|
||||||
|
"cancelMerge": {
|
||||||
|
"kind": "none" // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
}, // Duplicate card dialog: Cancel (merge preview).
|
||||||
|
"toggleView": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Compact / Full (merge preview).
|
||||||
|
"scrollUp": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
}, // Duplicate card dialog: Scroll up (merge preview). Repeats while held.
|
||||||
|
"scrollDown": {
|
||||||
|
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||||
|
"press": {
|
||||||
|
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||||
|
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||||
|
}, // Input that triggers the action.
|
||||||
|
"hold": null // Optional input held while pressing; null for none.
|
||||||
|
} // Duplicate card dialog: Scroll down (merge preview). Repeats while held.
|
||||||
|
}, // Controller layout for the duplicate card (field grouping) dialog, one entry per action. Set it with the Duplicate card (field grouping) group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
|
||||||
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
@@ -218,6 +520,14 @@
|
|||||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||||
}, // Overlay notification display behavior.
|
}, // Overlay notification display behavior.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Plugins
|
||||||
|
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
|
||||||
|
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
|
||||||
|
// Restart SubMiner after changing plugins.
|
||||||
|
// ==========================================
|
||||||
|
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcuts
|
||||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
@@ -462,6 +772,7 @@
|
|||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
|
"autoPauseVideoOnKeyboardMode": false, // Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is. Values: true | false
|
||||||
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
|
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
|
||||||
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
|
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
|
||||||
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
||||||
@@ -541,6 +852,22 @@
|
|||||||
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
|
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
|
||||||
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Subtitle Filters
|
||||||
|
// Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
|
||||||
|
// Applies to the overlay, sidebar, mined sentences and stats.
|
||||||
|
// Hot-reload: filter changes apply to the current subtitle file immediately.
|
||||||
|
// ==========================================
|
||||||
|
"subtitleFilter": {
|
||||||
|
"presets": {
|
||||||
|
"parentheses": false, // Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept. Values: true | false
|
||||||
|
"brackets": false, // Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues. Values: true | false
|
||||||
|
"speakerPrefix": false, // Remove a short name followed by a colon at the start of a line, as in 山田:元気? Values: true | false
|
||||||
|
"musicSymbols": false // Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed. Values: true | false
|
||||||
|
}, // Built-in filters. Each removes its matches and is off by default.
|
||||||
|
"customRules": [] // Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.
|
||||||
|
}, // Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Shared AI Provider
|
// Shared AI Provider
|
||||||
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||||
@@ -578,7 +905,7 @@
|
|||||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
"audio": "SentenceAudio", // Card field that receives generated sentence audio.
|
||||||
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
|
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
|
||||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||||
@@ -663,7 +990,8 @@
|
|||||||
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
||||||
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
||||||
|
"autoOpenWhenPrimarySubtitleMissing": false // Pause playback and open the Jimaku modal when a local video has no subtitle track in youtube.primarySubLanguages (Japanese by default). Values: true | false
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ Built into the overlay, not configurable:
|
|||||||
| Input | Action |
|
| Input | Action |
|
||||||
| ------------------------- | -------------------------------------------------- |
|
| ------------------------- | -------------------------------------------------- |
|
||||||
| `V` | Cycle primary subtitle bar: hidden, visible, hover |
|
| `V` | Cycle primary subtitle bar: hidden, visible, hover |
|
||||||
|
| `C` (keyboard-only mode) | Move by word or by character |
|
||||||
| Right-click | Pause or resume (outside the subtitle area) |
|
| Right-click | Pause or resume (outside the subtitle area) |
|
||||||
| Right-click + drag | Move the subtitles |
|
| Right-click + drag | Move the subtitles |
|
||||||
| Drop files on the overlay | Replace the mpv playlist |
|
| Drop files on the overlay | Replace the mpv playlist |
|
||||||
@@ -138,6 +139,8 @@ The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To run a [plugin](/plugins) action, use `["__plugin-action:<plugin id>/<action>"]` as the command.
|
||||||
|
|
||||||
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
|
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
|
||||||
|
|
||||||
## Automatic mpv bindings
|
## Automatic mpv bindings
|
||||||
|
|||||||
+42
-16
@@ -74,6 +74,10 @@ See [Launcher script](/launcher-script#video-picker) for picker and history deta
|
|||||||
|
|
||||||
Hovering subtitle text pauses mpv, and moving away resumes it. An open dictionary popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
|
Hovering subtitle text pauses mpv, and moving away resumes it. An open dictionary popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
|
||||||
|
|
||||||
|
**Pause Video In Keyboard-Only Mode** (`subtitleStyle.autoPauseVideoOnKeyboardMode`, off by default) pauses mpv when keyboard-only mode turns on, from the keyboard or a controller, and resumes it when the mode turns off. It only resumes a pause it made: if the video was already paused, or you played or paused it yourself in the meantime, leaving the mode leaves playback as it is.
|
||||||
|
|
||||||
|
Turning keyboard-only mode off also closes the dictionary popup, whether or not this setting is on.
|
||||||
|
|
||||||
You can drop files onto the overlay:
|
You can drop files onto the overlay:
|
||||||
|
|
||||||
- A video replaces what is playing. Hold `Shift` to add it to the playlist instead.
|
- A video replaces what is playing. Hold `Shift` to add it to the playlist instead.
|
||||||
@@ -104,6 +108,7 @@ subminer doctor # check dependencies, config, and the mpv soc
|
|||||||
subminer generate-subs video.mkv # make Japanese subtitles from the audio
|
subminer generate-subs video.mkv # make Japanese subtitles from the audio
|
||||||
subminer logs -e # export a log ZIP for bug reports
|
subminer logs -e # export a log ZIP for bug reports
|
||||||
subminer app --setup # reopen first-run setup
|
subminer app --setup # reopen first-run setup
|
||||||
|
subminer app --plugin-action <id>/<name> # run a plugin action
|
||||||
subminer -u # update SubMiner
|
subminer -u # update SubMiner
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -155,26 +160,47 @@ On Wayland, the tray icon only appears if your panel provides a StatusNotifier (
|
|||||||
You can drive the overlay with a gamepad.
|
You can drive the overlay with a gamepad.
|
||||||
|
|
||||||
1. Set `controller.enabled` to `true` in your config.
|
1. Set `controller.enabled` to `true` in your config.
|
||||||
2. Connect a controller. SubMiner uses the first one it sees.
|
2. Connect a controller and press a button. SubMiner uses the first one it sees and announces it through [`ankiConnect.behavior.notificationType`](/configuration#ankiconnect).
|
||||||
3. Press `Y` on the controller to turn on keyboard-only mode. The controller only works in this mode.
|
3. Press `Y` on the controller to turn on keyboard-only mode. Most controller actions only work in this mode.
|
||||||
4. Move between words with the left stick, press `A` to look one up, and `X` to mine it.
|
4. Move between words with the left stick, press `A` to look one up, and `X` to mine it.
|
||||||
|
|
||||||
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. `Alt+Shift+C` shows raw input values for unusual pads.
|
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want, or hold one button and press another to make a combo such as `L1` + D-pad right. Set **Button Style** to Xbox, Nintendo or PlayStation so buttons are named as printed on your controller; it is saved per controller. Changes apply right away. When two actions share an input, both fire, so their rows show a ⚠ warning naming the other action; this includes a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action. The window only sees a controller after you press one of its buttons; until then it shows the bindings of the controller in use as a preview, and editing stays locked. With several controllers connected, it selects the one you pressed last. `Alt+Shift+C` shows raw input values for unusual pads.
|
||||||
|
|
||||||
| Button | Action |
|
A combo fires each time you press its second button while the first is down, so holding `L1` and pressing right twice runs it twice. While a combo's hold button is down, other buttons only run combos that use it, and its press no longer drives a **(D-pad)** or stick row. A hold button can keep its own action: that action then runs when you let go, unless you pressed another bound button during the hold. The exception is **Show Secondary Subtitle (Hold)**, which can't wait for release, so its row warns if it shares a hold button.
|
||||||
| --------------------- | ------------------------------------ |
|
|
||||||
| `A` (South) | Look up the selected word |
|
|
||||||
| `B` (East) | Close the lookup |
|
|
||||||
| `X` (West) | Mine a card |
|
|
||||||
| `Y` (North) | Toggle keyboard-only mode |
|
|
||||||
| `L1` | Play the current Yomitan audio |
|
|
||||||
| `R1` | Next Yomitan audio source |
|
|
||||||
| `L3` | Pause or resume mpv |
|
|
||||||
| `Select` / `Minus` | Quit mpv |
|
|
||||||
| Left stick | Move between words, scroll the popup |
|
|
||||||
| Right stick (up/down) | Jump through the popup |
|
|
||||||
|
|
||||||
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support).
|
| Button | Action |
|
||||||
|
| --------------------- | -------------------------------------- |
|
||||||
|
| `A` (South) | Look up the selected word |
|
||||||
|
| `B` (East) | Close the lookup |
|
||||||
|
| `X` (West) | Mine a card |
|
||||||
|
| `Y` (North) | Toggle keyboard-only mode |
|
||||||
|
| `L1` | Play the current Yomitan audio |
|
||||||
|
| `R1` | Next Yomitan audio source |
|
||||||
|
| `L3` | Pause or resume mpv (any mode) |
|
||||||
|
| `Select` / `Minus` | Quit mpv |
|
||||||
|
| Left stick | Move between words, scroll the popup |
|
||||||
|
| Right stick (up/down) | Jump through the popup |
|
||||||
|
| Unbound by default | Show the secondary subtitle while held |
|
||||||
|
| Unbound by default | Previous word entry in the popup |
|
||||||
|
| Unbound by default | Next word entry in the popup |
|
||||||
|
| Unbound by default | Previous / next subtitle line |
|
||||||
|
| Unbound by default | Replay the current line, then pause |
|
||||||
|
| Unbound by default | Play the next line, then pause |
|
||||||
|
| Unbound by default | Move by word or by character |
|
||||||
|
|
||||||
|
The secondary subtitle hold works with or without keyboard-only mode. Assign it with **Learn** on the **Show Secondary Subtitle (Hold)** row in `Alt+C`. While held, the secondary subtitle shows as if its mode were `visible`; releasing the button returns it to your chosen mode.
|
||||||
|
|
||||||
|
The **Subtitles** rows in `Alt+C` do the same as the keyboard shortcuts: **Previous Subtitle** and **Next Subtitle** jump a line like `Shift+H` / `Shift+L` (a paused video stays paused), **Replay Subtitle** is `Ctrl+Shift+H` and **Play Next Subtitle** is `Ctrl+Shift+L`. Like the secondary subtitle hold and pause, they work with or without keyboard-only mode. If you put them on the D-pad, click **Clear** on the matching **(D-pad)** row under **Navigation** too (both rows warn until you do), or in keyboard-only mode the D-pad will also move between words or scroll the popup.
|
||||||
|
|
||||||
|
**Previous Entry** and **Next Entry** move one word entry at a time in the open popup, and Mine Card then mines the selected entry. They send Yomitan's default `Alt+Up` / `Alt+Down` entry shortcuts, so they stop working if you rebind those in Yomitan's keyboard settings. If you assign them to the D-pad, click **Clear** on the **Popup Scroll (D-pad)** row too, or the D-pad will also scroll the popup.
|
||||||
|
|
||||||
|
When the subtitle is split in the wrong place, a word can start in the middle of a box (in もっとハ|キ|ハキしなくちゃ, ハキハキ starts at the last character of the first box) and moving by word never reaches it. In keyboard-only mode, press `C` on the keyboard, or the **Toggle Character Navigation** button (unbound by default, under **Navigation** in `Alt+C`), to move one character at a time: the box turns orange and covers one character, and a lookup searches from that character, so Yomitan finds the longest word starting there. Press it again to go back to moving by word. It stays on until you switch it back, even after leaving keyboard-only mode.
|
||||||
|
|
||||||
|
The media timing review and duplicate card (field grouping) dialogs have their own controller layouts, which you can change in the **Review media timing** and **Duplicate card (field grouping)** groups of `Alt+C`; see [Review media timing](/anki-integration#with-a-controller) and [Field grouping](/anki-integration#field-grouping-with-a-controller).
|
||||||
|
|
||||||
|
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`, and the default pause button on `Start` instead of `L3`. The default right-stick popup jump does nothing on these controllers; click **Learn** on **Popup Jump (Stick)** and move the right stick up or down to set it. Remap anything else with `Alt+C`, which names buttons according to the layout your controller reports. All options are in [Configuration](/configuration#controller-support).
|
||||||
|
|
||||||
|
On macOS, a controller only reaches the app in front. When mpv is in front, a controller button brings the overlay to the front and then runs that button's action. With more than one controller connected, that first press only brings the overlay to the front; press the button again to run its action. Stick movements only work once the overlay is in front. Buttons pressed while another app is in front are left alone.
|
||||||
|
|
||||||
## Development profiles
|
## Development profiles
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ The same pattern works for n8n or Zapier workflows, Discord notifiers, or append
|
|||||||
|
|
||||||
## mpv script messages
|
## mpv script messages
|
||||||
|
|
||||||
SubMiner has no in-app plugin SDK. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script).
|
SubMiner also supports [JavaScript app plugins](/plugins) for playback events, mining events, actions, and custom UI. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script).
|
||||||
|
|
||||||
The mpv plugin accepts these script messages:
|
The mpv plugin accepts these script messages:
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ The mpv plugin accepts these script messages:
|
|||||||
script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug
|
script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug
|
||||||
```
|
```
|
||||||
|
|
||||||
The plugin also registers `subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts.
|
The plugin also registers `subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-overlay-loading-dismissed`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts.
|
||||||
|
|
||||||
While the app is connected to mpv, it also handles two AniSkip messages over the mpv IPC socket: `subminer-skip-intro` skips the intro, and `subminer-aniskip-refresh` reloads intro data, for example after your script changes title or episode metadata.
|
While the app is connected to mpv, it also handles two AniSkip messages over the mpv IPC socket: `subminer-skip-intro` skips the intro, and `subminer-aniskip-refresh` reloads intro data, for example after your script changes title or episode metadata.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Architecture Map
|
# Architecture Map
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-05-23
|
Last verified: 2026-11-01
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: runtime ownership, composition boundaries, or layering questions
|
Read when: runtime ownership, composition boundaries, or layering questions
|
||||||
|
|
||||||
@@ -30,17 +30,19 @@ Update checks and startup launcher migration share a serialized update-state sto
|
|||||||
|
|
||||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||||
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
|
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; the overlay assembly supplies window creation and subtitle refresh hooks.
|
||||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same media. For Jellyfin playback it reads the item's original-file stream (`toJellyfinOriginalStreamUrl`) into one reused output folder, where each run best-effort deletes earlier files and writes a uniquely named SRT; transcoded playback uses Jellyfin's audio index and only external references, since mpv's track indexes describe the transcode. Subprocess errors redact URL tokens; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same media. For Jellyfin playback it reads the item's original-file stream (`toJellyfinOriginalStreamUrl`) into one reused output folder, where each run best-effort deletes earlier files and writes a uniquely named SRT; transcoded playback uses Jellyfin's audio index and only external references, since mpv's track indexes describe the transcode. Subprocess errors redact URL tokens; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
||||||
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
|
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
|
||||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||||
|
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `src/main/assemblies/plugins.ts` builds the host. The playback assembly attaches each new mpv client so plugin listeners survive client replacement, the startup assembly starts plugins from the app-ready path after logging is configured, and the `will-quit` cleanup unloads them first, before any teardown. Plugins run in the main process with no sandbox. `src/plugin-api.ts` is the standalone public v1 type contract; internal mpv event and session-action types derive from it. Manifests may declare `apiVersion`, which is checked before loading code. Async activation is bounded and actions wait only for their own plugin. The host owns cached media snapshots and playback ids, including repeated loads of the same path. `ctx.signal` aborts as unloading starts; `ctx.onDispose` manages outside resources and late registrations are disposed immediately. Awaitable `ctx.mpv.request` uses the existing request/response transport. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `ctx.anki` carries the `card-mined` event with unique kept `noteIds` and per-original-note records. Sentence-card creation snapshots media and subtitle context before asynchronous work; the proxy snapshots before forwarding an upstream request. `AnkiIntegration` keeps provenance under original ids through merges and queued YouTube jobs; polling uses null provenance. The event is fed by `AnkiIntegration.setCardsReadyCallback`: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off); `AnkiIntegration` holds a sentence card's report until field grouping finishes, and the pending YouTube media queue reports notes whose media it is still waiting on (directly or through the note field grouping merged them into), which is later than `recordCardsMined` (stats counts at add time). Stats-dashboard mining does not emit it. `ctx.anki.invoke` sends one action to the effective `ankiConnect.url` with no retries. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
||||||
- `src/renderer/` owns overlay rendering and input behavior.
|
- `src/renderer/` owns overlay rendering and input behavior.
|
||||||
- `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/assemblies/` owns per-domain assemblies that `src/main.ts` builds once (see [Layering](./layering.md)).
|
||||||
- `src/main.ts` call sites invoke configured runtime handlers and runtime-object methods directly;
|
- `src/main.ts` call sites invoke configured runtime handlers and runtime-object methods directly;
|
||||||
do not add local pass-through wrappers around them.
|
do not add local pass-through wrappers around them.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Domain Ownership
|
# Domain Ownership
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-07-15
|
Last verified: 2026-11-01
|
||||||
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
|
||||||
|
|
||||||
@@ -19,21 +19,51 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
- Config system: `src/config/`; Anki resolution is composed by
|
- Config system: `src/config/`; Anki resolution is composed by
|
||||||
`src/config/resolve/anki-connect.ts` from focused resolvers in
|
`src/config/resolve/anki-connect.ts` from focused resolvers in
|
||||||
`src/config/resolve/anki-connect/`
|
`src/config/resolve/anki-connect/`
|
||||||
|
Config hot-reload and the settings window: `src/main/assemblies/settings.ts`
|
||||||
|
- App startup: app-ready sequence, headless known-word refresh, overlay startup prerequisites and
|
||||||
|
overlay runtime bootstrap: `src/main/assemblies/startup.ts`
|
||||||
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||||
|
Overlay windows, visibility, geometry and pointer interaction, Linux fullscreen handling, mpv
|
||||||
|
subtitle suppression while the overlay shows, and the overlay-window IPC handlers (modal
|
||||||
|
open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts`
|
||||||
|
Overlay-hosted modal openers: `src/main/assemblies/overlay-modals.ts`
|
||||||
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
||||||
|
mpv client creation, mpv event wiring, AniSkip, the autoplay-ready gate that releases mpv's
|
||||||
|
startup pause, managed local subtitle selection, subtitle prefetch, and subtitle sidebar state:
|
||||||
|
`src/main/assemblies/playback.ts`
|
||||||
|
- Keyboard input outside the renderer (overlay, global and numeric shortcuts, session bindings):
|
||||||
|
`src/main/assemblies/shortcuts.ts`
|
||||||
|
- Session actions (the dispatcher behind shortcuts, controller buttons, CLI commands and plugin
|
||||||
|
actions), the secondary-subtitle mode cycle, and the overlay renderer's mpv commands:
|
||||||
|
`src/main/assemblies/session-actions.ts`
|
||||||
|
- System tray: `src/main/assemblies/tray.ts`
|
||||||
|
- Yomitan/Hachidori extension loading, parser runtime deps, and dictionary settings windows:
|
||||||
|
`src/main/assemblies/yomitan.ts`
|
||||||
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
|
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
|
||||||
- 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/`
|
||||||
|
JLPT and frequency annotation lookups: `src/main/jlpt-runtime.ts`, `src/main/frequency-dictionary-runtime.ts`, assembled by `src/main/assemblies/dictionaries.ts`
|
||||||
|
Annotation toggles (a cycled runtime option over its config value): `src/main/runtime/annotation-options.ts`
|
||||||
|
Whisper subtitle generation runtime, modal and IPC: `src/main/assemblies/subtitle-generation.ts`
|
||||||
|
Subtitle text filters (`subtitleFilter`): `src/shared/subtitle-text-filter.ts`. Primary text is filtered at its two entry points, live `sub-text` in `mpv-protocol.ts` and parsed cues in `playback.ts`, so every consumer sees the same text and live/cue matching still works. A config change re-reads `sub-text` and re-parses the active track (the prefetch controller's parse key).
|
||||||
|
- First-run setup: `src/main/runtime/first-run-setup-*.ts`, assembled by `src/main/assemblies/first-run-setup.ts`
|
||||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||||
|
Card mining handlers, the pending lookup context, field grouping, and media timing review wiring:
|
||||||
|
`src/main/assemblies/mining.ts`
|
||||||
- Media timing review: `src/main/runtime/media-timing-review.ts` owns the review and hidden `MediaTimingPreviewSession`. The session waits for mpv readiness and acknowledges playback commands; its `time-pos` observations reach the modal through `media-timing-review:preview-position`. The renderer places the cursor from those timestamps and uses an inactivity timeout instead of timing the clip itself.
|
- Media timing review: `src/main/runtime/media-timing-review.ts` owns the review and hidden `MediaTimingPreviewSession`. The session waits for mpv readiness and acknowledges playback commands; its `time-pos` observations reach the modal through `media-timing-review:preview-position`. The renderer places the cursor from those timestamps and uses an inactivity timeout instead of timing the clip itself.
|
||||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
- Immersion tracking: `src/core/services/immersion-tracker/`; tracker startup, the stats server,
|
||||||
|
the stats overlay window options, and the `--stats` command are assembled by `src/main/assemblies/stats.ts`
|
||||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||||
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
|
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
|
||||||
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window.
|
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window.
|
||||||
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`.
|
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`. The sync window and auto-sync scheduler live in `src/main/assemblies/sync.ts`.
|
||||||
`snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it.
|
`snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it.
|
||||||
- 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-*` (called by `src/main/assemblies/anilist.ts`), `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`, assembled by `src/main/assemblies/character-dictionary.ts`
|
||||||
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`,
|
||||||
|
assembled by `src/main/assemblies/jellyfin.ts`
|
||||||
|
- YouTube playback, subtitles, media cache, and in-app browser: `src/core/services/youtube/`,
|
||||||
|
`src/main/runtime/youtube-*`, assembled by `src/main/assemblies/youtube.ts`
|
||||||
|
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), assembled by `src/main/assemblies/plugins.ts`; `src/renderer/modals/plugin-panel*.ts`; examples in `assets/plugins/`
|
||||||
- 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 HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||||
in `src/core/services/stats-server/`
|
in `src/core/services/stats-server/`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Layering Rules
|
# Layering Rules
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-05-23
|
Last verified: 2026-10-31
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: deciding whether a dependency direction is acceptable
|
Read when: deciding whether a dependency direction is acceptable
|
||||||
|
|
||||||
@@ -19,6 +19,23 @@ Renderer, launcher, plugin, and stats each keep their own local layering and sho
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Keep `src/main.ts` thin; wire, do not implement.
|
- Keep `src/main.ts` thin; wire, do not implement.
|
||||||
|
- Pass deps straight to a handler factory (`createXHandler({...})`). Do not add
|
||||||
|
deps builders (`createBuild*MainDepsHandler`, `*-main-deps.ts`) that re-wrap deps.
|
||||||
|
Adaptation logic (defaults, appState getters/setters, shared state) belongs in the
|
||||||
|
handler or in a named adapter that returns the deps directly.
|
||||||
|
- Group a domain's runtimes in one assembly under `src/main/assemblies/` (for example
|
||||||
|
`createYoutubeAssembly(deps)`). It owns the domain's state, takes an explicit deps
|
||||||
|
object, and returns its runtimes; `main.ts` builds it once and calls through the result.
|
||||||
|
Siblings built later in `main.ts` are passed as getters or callbacks, and construction must
|
||||||
|
not call them.
|
||||||
|
- An assembly that answers renderer IPC or CLI commands returns them as typed slices
|
||||||
|
(`IpcSlice<...>` / `CliSlice<...>` from `src/main/assemblies/slices.ts`). `main.ts`
|
||||||
|
spreads the slices into the registration and checks the result with
|
||||||
|
`satisfies Required<...>`, so a missing handler fails typecheck. Only app-level handlers
|
||||||
|
with no owning assembly stay inline in `main.ts`.
|
||||||
|
- Electron modules that value-import named Electron APIs (`dialog`, `screen`, `shell`,
|
||||||
|
`clipboard`, `globalShortcut`, `Tray`, ...) cannot load under `bun test`. Pass those APIs
|
||||||
|
into an assembly as deps instead of importing them there.
|
||||||
- Prefer injecting dependencies from `src/main/` instead of reaching outward from core services.
|
- Prefer injecting dependencies from `src/main/` instead of reaching outward from core services.
|
||||||
- Keep side effects explicit and close to composition boundaries.
|
- Keep side effects explicit and close to composition boundaries.
|
||||||
- Put reusable business logic in focused services, not in top-level lifecycle files.
|
- Put reusable business logic in focused services, not in top-level lifecycle files.
|
||||||
|
|||||||
@@ -12,20 +12,42 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
|
|||||||
- collapse multiple network round-trips into one request
|
- collapse multiple network round-trips into one request
|
||||||
- keep heavy chart shaping close to tracker/query logic
|
- keep heavy chart shaping close to tracker/query logic
|
||||||
|
|
||||||
|
## Date Ranges And Buckets
|
||||||
|
|
||||||
|
- A range resolves to calendar days (`resolveTrendWindow`). Rollups are selected by day span, never by "last N rows", so sparse activity cannot pull older days into a range. Custom ranges are `{ startDay, endDay }` from `range=custom&fromDay&toDay`.
|
||||||
|
- `all` spans the recorded data, not today.
|
||||||
|
- Day and week buckets read daily rollups. Month buckets read monthly rollups for months wholly in range, because the `minimal` and `deep-history` retention presets prune daily rollups first, and daily rollups for a partly covered month. Weeks start on Monday.
|
||||||
|
- `buckets` is the one axis for `activity`, `progress`, and `ratios`, index for index. Charts use it to map a clicked bar back to its days.
|
||||||
|
- Each `progress` point is the total at the end of its period, and the chart adds the zero starting point.
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
- rollup-backed:
|
- rollup-backed:
|
||||||
- activity charts
|
- activity charts and running totals
|
||||||
- cumulative watch/cards/tokens/sessions trends
|
- `titleTimeline`: sparse per-title minutes for active days only, because a dense title x day payload grew to several MB
|
||||||
- per-anime watch/cards/tokens/episodes series
|
|
||||||
- session-metric-backed:
|
- session-metric-backed:
|
||||||
- lookup trends
|
- lookups and lookup rate
|
||||||
- lookup rate trends
|
- `watchTimeByDayHour`, attributed to the hour each session started
|
||||||
- watch-time by day-of-week/hour
|
- rollup word counts and session metrics share one session query per request (`getTrendSessionRows`), so the per-session display-word count runs once instead of once per rollup query
|
||||||
|
- `imm_videos.watched`-backed:
|
||||||
|
- finished episodes count each watched video once, on the last day it has a rollup, since the flag has no timestamp
|
||||||
- vocabulary-backed:
|
- vocabulary-backed:
|
||||||
- new-words trend reads permanent daily lexical rollups
|
- `getNewWordFirstSeenRows` folds `imm_words` rows into dictionary words first, because the table is unique on (headword, word, reading) and stores each conjugation separately
|
||||||
- rollup rows count only vocabulary-visible tokens and normalize mixed legacy timestamp units
|
- the aggregate scans every word, so it is cached per connection until `total_changes()` or `PRAGMA data_version` moves
|
||||||
- a persisted rollup version invalidates stale materializations and triggers an atomic background rebuild
|
- Trends new-word metrics exclude names; the Vocabulary tab offers both
|
||||||
|
- `imm_lexical_daily_rollups` is no longer read for new-word counts (it counts surface forms). Its version state still gates the `vocabulary_visible` backfill.
|
||||||
|
|
||||||
|
## Vocabulary Word List
|
||||||
|
|
||||||
|
`/api/stats/vocabulary/words` (`query-vocabulary-words.ts`) folds `imm_words` into dictionary words on the server and filters, sorts, and pages there, because a client-side list can only hold a slice of the table. The fold is cached per connection until a write, the same way as the new-word aggregate. `titles` is computed for the returned rows; sorting or filtering by it needs every word's count (about 900ms), so that map tolerates five minutes of staleness. Names are detected with `isProperNounPos2`, since merged tokens store tags like `固有名詞|一般`.
|
||||||
|
|
||||||
|
## Rates
|
||||||
|
|
||||||
|
Rates are `null` when the underlying data is under 30 active minutes or 2,000 words, and charts draw that as a gap. Daily views rate a trailing 7 calendar days, volume-weighted, which is why the query loads days before the range. Week and month views rate each bucket.
|
||||||
|
|
||||||
|
## Title Filter
|
||||||
|
|
||||||
|
`title` narrows totals, charts, and patterns. `librarySummary` and `titleTimeline` stay complete so the page can switch titles. New words have no per-title record, so `learning` and `summary.current.newWords` are `null` while filtered.
|
||||||
|
|
||||||
## Metric Semantics
|
## Metric Semantics
|
||||||
|
|
||||||
@@ -39,6 +61,6 @@ Stats data crosses the process boundary over HTTP only. Shared JSON models live
|
|||||||
|
|
||||||
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 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 (including the title timeline layout), 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.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-08-19
|
Last verified: 2026-11-01
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -19,13 +19,14 @@ subtitles do not draw.
|
|||||||
|
|
||||||
## Entry Points
|
## Entry Points
|
||||||
|
|
||||||
- `src/main.ts` calls `primeCurrentSubtitleForVisibleOverlay()` when manual visible-overlay show
|
- The overlay assembly (`src/main/assemblies/overlay.ts`) calls
|
||||||
paths run.
|
`primeCurrentSubtitleForVisibleOverlay()` when manual visible-overlay show paths run.
|
||||||
- `src/main.ts` calls `restoreVisibleOverlayWindowShapeForShow()` before visible-overlay show
|
- It calls `restoreVisibleOverlayWindowShapeForShow()` before visible-overlay show actions on
|
||||||
actions on Linux, and `resetVisibleOverlayInputState()` restores a full shape instead of applying
|
Linux, and `resetVisibleOverlayInputState()` restores a full shape instead of applying an empty
|
||||||
an empty shape.
|
shape (both in `src/main/runtime/visible-overlay-interaction-runtime.ts`).
|
||||||
- `src/main.ts` also restores the Linux/X11 shape after applying mpv overlay bounds, so a newly
|
- `src/main/runtime/overlay-geometry-runtime.ts` also restores the Linux/X11 shape after applying
|
||||||
created 800x600 hidden Electron window cannot keep clipping after it is resized to mpv geometry.
|
mpv overlay bounds, so a newly created 800x600 hidden Electron window cannot keep clipping after
|
||||||
|
it is resized to mpv geometry.
|
||||||
- `primeCurrentSubtitleForVisibleOverlay()` delegates to
|
- `primeCurrentSubtitleForVisibleOverlay()` delegates to
|
||||||
`primeVisibleOverlaySubtitleFromMpv()` in `src/main/runtime/current-subtitle-snapshot.ts`.
|
`primeVisibleOverlaySubtitleFromMpv()` in `src/main/runtime/current-subtitle-snapshot.ts`.
|
||||||
- `restoreVisibleOverlayWindowShapeForShow()` delegates to `restoreLinuxOverlayWindowShape()` in
|
- `restoreVisibleOverlayWindowShapeForShow()` delegates to `restoreLinuxOverlayWindowShape()` in
|
||||||
@@ -54,7 +55,7 @@ Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchServ
|
|||||||
the matching `subtitleProcessingController` method, giving the visible overlay priority over
|
the matching `subtitleProcessingController` method, giving the visible overlay priority over
|
||||||
background prefetch work. Prefetch is not re-centered here: restarting the run per line
|
background prefetch work. Prefetch is not re-centered here: restarting the run per line
|
||||||
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
|
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
|
||||||
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
|
seeks restart it (see the time-pos handling in `src/main/assemblies/playback.ts`).
|
||||||
|
|
||||||
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
|
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
|
||||||
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
|
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ good test looks like here.
|
|||||||
|
|
||||||
## Do Not Write
|
## Do Not Write
|
||||||
|
|
||||||
- Pass-through tests: a builder or handler that only forwards deps (`*-main-deps.ts`,
|
- Pass-through tests: a handler or adapter that only forwards deps
|
||||||
`*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape.
|
(`*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape.
|
||||||
- Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML
|
- Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML
|
||||||
and asserting with regexes. If the invariant matters, extract the logic and run it
|
and asserting with regexes. If the invariant matters, extract the logic and run it
|
||||||
(see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the
|
(see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the
|
||||||
@@ -44,6 +44,5 @@ good test looks like here.
|
|||||||
|
|
||||||
## Known Debt
|
## Known Debt
|
||||||
|
|
||||||
- `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still
|
- `src/renderer/renderer-init-order.test.ts` still regexes `renderer.ts` to guard startup
|
||||||
regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a
|
ordering. Replace it with a behavioral test as that logic moves out of `renderer.ts`.
|
||||||
behavioral one as the logic moves out of `main.ts` closures.
|
|
||||||
@@ -123,9 +123,11 @@ whenever the behavior does not depend on a specific compositor.
|
|||||||
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
|
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
|
||||||
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
||||||
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
||||||
`startE2eSession()`. Pass `config` to override settings for a scenario file.
|
`startE2eSession()`. Pass `config` to override settings for a scenario file, and `files`
|
||||||
|
to write files such as plugins into the isolated config dir before launch.
|
||||||
- On a failed startup the temp dir is kept and its path is in the error. Set
|
- On a failed startup the temp dir is kept and its path is in the error. Set
|
||||||
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
|
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`,
|
||||||
|
`mpv-stdio.log`).
|
||||||
|
|
||||||
For exploratory checks, keep one session up and poke at it:
|
For exploratory checks, keep one session up and poke at it:
|
||||||
|
|
||||||
|
|||||||
+42
-4
@@ -15,25 +15,41 @@ export type MpvClient = {
|
|||||||
|
|
||||||
type MpvReply = { request_id?: number; error?: string; data?: unknown };
|
type MpvReply = { request_id?: number; error?: string; data?: unknown };
|
||||||
|
|
||||||
|
function videoOutputArgs(display: E2eDisplay): string[] {
|
||||||
|
// Xvfb has no GPU; the software X11 output always works there.
|
||||||
|
if (display.kind === 'xvfb') return ['--vo=x11'];
|
||||||
|
// gpu-next's Vulkan path (libplacebo over MoltenVK) segfaults on the first frame
|
||||||
|
// on GitHub's paravirtual macOS GPU with libplacebo 7.387. `--vo=libmpv` is mpv's
|
||||||
|
// cocoa-cb OpenGL output, which avoids it and still opens the window the overlay tracks.
|
||||||
|
// Each new decoder asks that render thread for a direct-rendering image and waits with no
|
||||||
|
// timeout. cocoa-cb only answers from its main queue, which can stall on this runner, so a
|
||||||
|
// `loadfile` could freeze mpv's core and its IPC for good. `--vd-lavc-dr=no` skips the request.
|
||||||
|
if (process.platform === 'darwin') return ['--vo=libmpv', '--vd-lavc-dr=no'];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function startMpv(options: {
|
export async function startMpv(options: {
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
mediaPath: string;
|
mediaPath: string;
|
||||||
subtitlePath: string;
|
subtitlePath: string;
|
||||||
|
/** mpv's own verbose log (`--log-file`); `--no-terminal` silences its console output. */
|
||||||
logPath: string;
|
logPath: string;
|
||||||
|
/** Process stdout/stderr, which still catches crash output from mpv's libraries. */
|
||||||
|
stdioLogPath: string;
|
||||||
display: E2eDisplay;
|
display: E2eDisplay;
|
||||||
}): Promise<ChildProcess> {
|
}): Promise<ChildProcess> {
|
||||||
const log = fs.openSync(options.logPath, 'w');
|
const log = fs.openSync(options.stdioLogPath, 'w');
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
'mpv',
|
'mpv',
|
||||||
[
|
[
|
||||||
'--no-config',
|
'--no-config',
|
||||||
'--no-terminal',
|
'--no-terminal',
|
||||||
|
`--log-file=${options.logPath}`,
|
||||||
'--pause',
|
'--pause',
|
||||||
'--keep-open=yes',
|
'--keep-open=yes',
|
||||||
'--ao=null',
|
'--ao=null',
|
||||||
`--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`,
|
`--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`,
|
||||||
// Xvfb has no GPU; the software X11 output always works there.
|
...videoOutputArgs(options.display),
|
||||||
...(options.display.kind === 'xvfb' ? ['--vo=x11'] : []),
|
|
||||||
`--input-ipc-server=${options.socketPath}`,
|
`--input-ipc-server=${options.socketPath}`,
|
||||||
`--sub-file=${options.subtitlePath}`,
|
`--sub-file=${options.subtitlePath}`,
|
||||||
options.mediaPath,
|
options.mediaPath,
|
||||||
@@ -58,13 +74,31 @@ function tryConnect(socketPath: string): Promise<net.Socket | null> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectMpv(socketPath: string): Promise<MpvClient> {
|
/**
|
||||||
|
* Connects to mpv's JSON IPC socket. Once the socket closes (mpv exited or
|
||||||
|
* crashed), pending and later commands reject instead of hanging until the test
|
||||||
|
* timeout; `describeClose` adds context such as mpv's exit status.
|
||||||
|
*/
|
||||||
|
export async function connectMpv(
|
||||||
|
socketPath: string,
|
||||||
|
describeClose: () => string = () => 'mpv IPC connection closed',
|
||||||
|
): Promise<MpvClient> {
|
||||||
const socket = await waitUntil(() => tryConnect(socketPath), {
|
const socket = await waitUntil(() => tryConnect(socketPath), {
|
||||||
description: `mpv IPC socket ${socketPath}`,
|
description: `mpv IPC socket ${socketPath}`,
|
||||||
});
|
});
|
||||||
const pending = new Map<number, (reply: MpvReply) => void>();
|
const pending = new Map<number, (reply: MpvReply) => void>();
|
||||||
let nextRequestId = 1;
|
let nextRequestId = 1;
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
let closedError: Error | null = null;
|
||||||
|
|
||||||
|
const failPending = () => {
|
||||||
|
closedError ??= new Error(describeClose());
|
||||||
|
for (const settle of pending.values()) settle({ error: closedError.message });
|
||||||
|
pending.clear();
|
||||||
|
};
|
||||||
|
socket.on('close', failPending);
|
||||||
|
// 'close' always follows 'error'; listening here keeps the error from being thrown.
|
||||||
|
socket.on('error', () => {});
|
||||||
|
|
||||||
socket.setEncoding('utf8');
|
socket.setEncoding('utf8');
|
||||||
socket.on('data', (chunk: string) => {
|
socket.on('data', (chunk: string) => {
|
||||||
@@ -83,6 +117,10 @@ export async function connectMpv(socketPath: string): Promise<MpvClient> {
|
|||||||
return {
|
return {
|
||||||
command: (...args) =>
|
command: (...args) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
if (closedError) {
|
||||||
|
reject(new Error(`mpv ${JSON.stringify(args)} failed: ${closedError.message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const requestId = nextRequestId++;
|
const requestId = nextRequestId++;
|
||||||
pending.set(requestId, (reply) => {
|
pending.set(requestId, (reply) => {
|
||||||
if (reply.error === 'success') resolve(reply.data);
|
if (reply.error === 'success') resolve(reply.data);
|
||||||
|
|||||||
+51
-23
@@ -28,6 +28,8 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
|||||||
export type E2eSessionOptions = {
|
export type E2eSessionOptions = {
|
||||||
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
||||||
config?: JsonObject;
|
config?: JsonObject;
|
||||||
|
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
|
||||||
|
files?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppTarget = {
|
export type AppTarget = {
|
||||||
@@ -57,6 +59,8 @@ export type E2eSession = {
|
|||||||
mainProcess: () => Promise<CdpPage>;
|
mainProcess: () => Promise<CdpPage>;
|
||||||
/** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */
|
/** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */
|
||||||
runAppCommand: (...args: string[]) => Promise<void>;
|
runAppCommand: (...args: string[]) => Promise<void>;
|
||||||
|
/** After loading a file: waits for the app's autoplay resume, then leaves mpv paused. */
|
||||||
|
settleAfterResume: () => Promise<void>;
|
||||||
dispose: () => Promise<void>;
|
dispose: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,6 +159,30 @@ async function openPage(cdpPort: number, urlPart: string): Promise<CdpPage> {
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mpv starts paused and the app resumes each newly loaded file once the overlay
|
||||||
|
// and tokenizer are ready, so that resume marks the file as ready. Scenarios
|
||||||
|
// expect a paused player, and the app retries its resume for a moment, so keep
|
||||||
|
// pausing until the pause has held for longer than that retry window.
|
||||||
|
async function settleAfterResume(mpv: MpvClient): Promise<void> {
|
||||||
|
await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, {
|
||||||
|
description: 'SubMiner to resume playback once the file is ready',
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
});
|
||||||
|
let pausedSince: number | null = null;
|
||||||
|
await waitUntil(
|
||||||
|
async () => {
|
||||||
|
if ((await mpv.command('get_property', 'pause')) !== true) {
|
||||||
|
pausedSince = null;
|
||||||
|
await mpv.command('set_property', 'pause', true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pausedSince ??= Date.now();
|
||||||
|
return Date.now() - pausedSince >= 1_500;
|
||||||
|
},
|
||||||
|
{ description: 'playback to stay paused after the resume', timeoutMs: 40_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function startE2eSession(options: E2eSessionOptions = {}): Promise<E2eSession> {
|
export async function startE2eSession(options: E2eSessionOptions = {}): Promise<E2eSession> {
|
||||||
if (!fs.existsSync(path.join(repoRoot, 'dist', 'main-entry.js'))) {
|
if (!fs.existsSync(path.join(repoRoot, 'dist', 'main-entry.js'))) {
|
||||||
throw new Error('dist/ is missing. Run `bun run build` before the e2e harness.');
|
throw new Error('dist/ is missing. Run `bun run build` before the e2e harness.');
|
||||||
@@ -193,6 +221,15 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
|
|||||||
|
|
||||||
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
||||||
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
||||||
|
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
|
||||||
|
const filePath = path.resolve(userDataDir, relativePath);
|
||||||
|
const inside = path.relative(userDataDir, filePath);
|
||||||
|
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
|
||||||
|
throw new Error(`E2E file path must stay inside the config dir: ${relativePath}`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, content);
|
||||||
|
}
|
||||||
|
|
||||||
const env: EnvDelta = {
|
const env: EnvDelta = {
|
||||||
set: {
|
set: {
|
||||||
@@ -265,39 +302,29 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
|
|||||||
settings.close();
|
settings.close();
|
||||||
await stopProcess(seeder.child);
|
await stopProcess(seeder.child);
|
||||||
|
|
||||||
|
const mpvLogPath = path.join(root, 'mpv.log');
|
||||||
|
const mpvStdioLogPath = path.join(root, 'mpv-stdio.log');
|
||||||
const mpvProcess = await startMpv({
|
const mpvProcess = await startMpv({
|
||||||
socketPath: mpvSocketPath,
|
socketPath: mpvSocketPath,
|
||||||
mediaPath,
|
mediaPath,
|
||||||
subtitlePath,
|
subtitlePath,
|
||||||
logPath: path.join(root, 'mpv.log'),
|
logPath: mpvLogPath,
|
||||||
|
stdioLogPath: mpvStdioLogPath,
|
||||||
display,
|
display,
|
||||||
});
|
});
|
||||||
teardown.push(() => stopProcess(mpvProcess));
|
teardown.push(() => stopProcess(mpvProcess));
|
||||||
const mpv = await connectMpv(mpvSocketPath);
|
const mpv = await connectMpv(mpvSocketPath, () => {
|
||||||
|
const { exitCode, signalCode } = mpvProcess;
|
||||||
|
const status =
|
||||||
|
exitCode === null && signalCode === null
|
||||||
|
? 'mpv IPC connection closed'
|
||||||
|
: `mpv exited (code ${String(exitCode)}, signal ${String(signalCode)})`;
|
||||||
|
return `${status}; see ${mpvLogPath} and ${mpvStdioLogPath}`;
|
||||||
|
});
|
||||||
teardown.push(mpv.close);
|
teardown.push(mpv.close);
|
||||||
|
|
||||||
const app = await launchApp(['--start']);
|
const app = await launchApp(['--start']);
|
||||||
// mpv starts paused and the app resumes it once the overlay and tokenizer
|
await settleAfterResume(mpv);
|
||||||
// are ready, so that resume marks the end of startup.
|
|
||||||
await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, {
|
|
||||||
description: 'SubMiner to resume playback after overlay startup',
|
|
||||||
timeoutMs: 30_000,
|
|
||||||
});
|
|
||||||
// Hand scenarios a paused player. The app retries its resume for a moment,
|
|
||||||
// so keep pausing until the pause has held for longer than that retry window.
|
|
||||||
let pausedSince: number | null = null;
|
|
||||||
await waitUntil(
|
|
||||||
async () => {
|
|
||||||
if ((await mpv.command('get_property', 'pause')) !== true) {
|
|
||||||
pausedSince = null;
|
|
||||||
await mpv.command('set_property', 'pause', true);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
pausedSince ??= Date.now();
|
|
||||||
return Date.now() - pausedSince >= 1_500;
|
|
||||||
},
|
|
||||||
{ description: 'playback to stay paused after startup', timeoutMs: 40_000 },
|
|
||||||
);
|
|
||||||
const pages: CdpPage[] = [];
|
const pages: CdpPage[] = [];
|
||||||
teardown.push(() => pages.forEach((page) => page.close()));
|
teardown.push(() => pages.forEach((page) => page.close()));
|
||||||
|
|
||||||
@@ -321,6 +348,7 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
|
|||||||
return page;
|
return page;
|
||||||
},
|
},
|
||||||
runAppCommand: (...args) => runAppCommand(target, args),
|
runAppCommand: (...args) => runAppCommand(target, args),
|
||||||
|
settleAfterResume: () => settleAfterResume(mpv),
|
||||||
dispose,
|
dispose,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ let started: Promise<Started> | undefined;
|
|||||||
|
|
||||||
function useSession(): Promise<Started> {
|
function useSession(): Promise<Started> {
|
||||||
started ??= (async () => {
|
started ??= (async () => {
|
||||||
const session = await startE2eSession();
|
const session = await startE2eSession({ config: { controller: { enabled: true } } });
|
||||||
try {
|
try {
|
||||||
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -173,6 +173,12 @@ test(
|
|||||||
const delay = 2;
|
const delay = 2;
|
||||||
await session.mpv.command('set_property', 'sub-delay', delay);
|
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||||
try {
|
try {
|
||||||
|
// The previous test leaves this cue on screen, so blank the line first; otherwise the wait
|
||||||
|
// below passes before the seek lands and mining can catch mpv's empty mid-seek sub-text.
|
||||||
|
await session.mpv.command('seek', FIXTURE_CUES[0].start / 2, 'absolute+exact');
|
||||||
|
await overlay.waitFor(`document.getElementById('subtitleRoot').textContent === ''`, {
|
||||||
|
description: 'overlay to clear the line before the first delayed cue',
|
||||||
|
});
|
||||||
// Past the cue's own end, so the line is only on screen because of the delay.
|
// Past the cue's own end, so the line is only on screen because of the delay.
|
||||||
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
|
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
|
||||||
await overlay.waitFor(
|
await overlay.waitFor(
|
||||||
@@ -267,6 +273,93 @@ test(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// macOS and Windows keep a hidden modal renderer alive, and hidden windows keep polling because
|
||||||
|
// background throttling is off. A fake pad goes to every renderer, as a real pad would.
|
||||||
|
// Linux has no such window, and under Xvfb without a window manager the overlay reports hidden,
|
||||||
|
// so requestAnimationFrame and with it the controller poll never run. Bun's node:test ignores the
|
||||||
|
// `skip` option, so this picks `test.skip` instead.
|
||||||
|
const testOutsideLinux = process.platform === 'linux' ? test.skip : test;
|
||||||
|
testOutsideLinux(
|
||||||
|
'one controller press toggles mpv pause once across every window',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session } = await useSession();
|
||||||
|
const START_BUTTON = 9;
|
||||||
|
const targets = (await listCdpTargets(session.cdpPort)).filter((target) =>
|
||||||
|
target.url.includes('renderer/index.html'),
|
||||||
|
);
|
||||||
|
const renderers = await Promise.all(targets.map(connectCdpPage));
|
||||||
|
const everyRenderer = (expression: string) =>
|
||||||
|
Promise.all(renderers.map((renderer) => renderer.evaluate(expression)));
|
||||||
|
const nextPolls = `new Promise((done) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => done(true))))`;
|
||||||
|
try {
|
||||||
|
await everyRenderer(`(() => {
|
||||||
|
window.__fakePadPressed = false;
|
||||||
|
Object.defineProperty(navigator, 'getGamepads', {
|
||||||
|
configurable: true,
|
||||||
|
value: () => [{
|
||||||
|
id: 'E2E Pad (STANDARD GAMEPAD)',
|
||||||
|
index: 0,
|
||||||
|
connected: true,
|
||||||
|
mapping: 'standard',
|
||||||
|
axes: [0, 0, 0, 0],
|
||||||
|
buttons: Array.from({ length: 17 }, (_, index) => {
|
||||||
|
const pressed = window.__fakePadPressed && index === ${START_BUTTON};
|
||||||
|
return { value: pressed ? 1 : 0, pressed, touched: pressed };
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await everyRenderer(nextPolls);
|
||||||
|
await session.mpv.command('set_property', 'pause', true);
|
||||||
|
|
||||||
|
await everyRenderer('window.__fakePadPressed = true');
|
||||||
|
await everyRenderer(nextPolls);
|
||||||
|
await everyRenderer('window.__fakePadPressed = false');
|
||||||
|
await everyRenderer(nextPolls);
|
||||||
|
// Long enough for a second `cycle pause` to reach mpv and cancel the first.
|
||||||
|
await new Promise((done) => setTimeout(done, 300));
|
||||||
|
|
||||||
|
assert.equal(await session.mpv.command('get_property', 'pause'), false);
|
||||||
|
} finally {
|
||||||
|
await everyRenderer('delete navigator.getGamepads');
|
||||||
|
renderers.forEach((renderer) => renderer.close());
|
||||||
|
await session.mpv.command('set_property', 'pause', true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'controller combos survive the real save path and can be rebound to one button',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { overlay } = await useSession();
|
||||||
|
const L1 = { kind: 'button', buttonIndex: 4 };
|
||||||
|
const DPAD_LEFT = { kind: 'button', buttonIndex: 14 };
|
||||||
|
const DPAD_RIGHT = { kind: 'button', buttonIndex: 15 };
|
||||||
|
const save = (bindings: Record<string, unknown>) =>
|
||||||
|
overlay.evaluate<Record<string, unknown>>(
|
||||||
|
`(async () => {
|
||||||
|
await window.electronAPI.saveControllerConfig(${JSON.stringify({ bindings })});
|
||||||
|
return (await window.electronAPI.getControllerConfig()).bindings;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await save({ previousEntry: { ...DPAD_LEFT, hold: L1 } });
|
||||||
|
const bothCombos = await save({ nextEntry: { ...DPAD_RIGHT, hold: L1 } });
|
||||||
|
assert.deepEqual(bothCombos.previousEntry, { ...DPAD_LEFT, hold: L1 });
|
||||||
|
assert.deepEqual(bothCombos.nextEntry, { ...DPAD_RIGHT, hold: L1 });
|
||||||
|
|
||||||
|
const rebound = await save({ previousEntry: DPAD_LEFT, nextEntry: { kind: 'none' } });
|
||||||
|
assert.deepEqual(rebound.previousEntry, DPAD_LEFT);
|
||||||
|
assert.deepEqual(rebound.nextEntry, { kind: 'none' });
|
||||||
|
|
||||||
|
await save({ previousEntry: { kind: 'none' } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Runs last: it leaves the lookup popup open over the overlay.
|
// Runs last: it leaves the lookup popup open over the overlay.
|
||||||
test(
|
test(
|
||||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test, { after } from 'node:test';
|
||||||
|
import type { CdpPage } from './harness/cdp';
|
||||||
|
import { FIXTURE_CUES } from './harness/fixtures';
|
||||||
|
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
|
||||||
|
import { waitUntil } from './harness/wait';
|
||||||
|
|
||||||
|
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
|
||||||
|
// session boots inside whichever test runs first and every test budgets for it.
|
||||||
|
const TEST_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
// Opens a panel describing the subtitle context and a child process result, and
|
||||||
|
// logs when the panel's signal aborts so the test can see the close reach main.
|
||||||
|
const PROBE_PLUGIN = `
|
||||||
|
const { ipcMain } = require('electron');
|
||||||
|
|
||||||
|
exports.activate = (ctx) => {
|
||||||
|
ipcMain.handle('e2e-probe:info', () => ({ dataDir: ctx.dataDir, greeting: ctx.settings.greeting }));
|
||||||
|
ctx.onDispose(() => ipcMain.removeHandler('e2e-probe:info'));
|
||||||
|
ctx.media.on('change', (media) => ctx.log.info('probe media ' + JSON.stringify(media)));
|
||||||
|
ctx.anki.on('card-mined', ({ notes }) => ctx.log.info('probe mined ' + JSON.stringify(notes)));
|
||||||
|
ctx.actions.register('subtitle', () => ctx.log.info('probe subtitle ' + JSON.stringify(ctx.subtitles.current())));
|
||||||
|
ctx.actions.register('request', async () => {
|
||||||
|
const title = await ctx.mpv.request(['get_property', 'media-title']);
|
||||||
|
ctx.log.info('probe request ' + JSON.stringify(title));
|
||||||
|
try { await ctx.mpv.request(['subminer-no-such-command']); }
|
||||||
|
catch { ctx.log.info('probe request rejected'); }
|
||||||
|
});
|
||||||
|
ctx.actions.register('window', () => {
|
||||||
|
ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', title: 'E2E Probe Window' });
|
||||||
|
});
|
||||||
|
ctx.mpv.on('end-file', ({ reason }) => ctx.log.info('probe end-file ' + reason));
|
||||||
|
ctx.actions.register('show', () => {
|
||||||
|
const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 });
|
||||||
|
const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' });
|
||||||
|
panel.signal.addEventListener('abort', () => ctx.log.info('probe panel aborted'));
|
||||||
|
ctx
|
||||||
|
.exec(process.execPath, ['-e', 'process.stdout.write("child-ok")'], {
|
||||||
|
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||||
|
signal: panel.signal,
|
||||||
|
})
|
||||||
|
.then((result) =>
|
||||||
|
panel.update({
|
||||||
|
markdown: [
|
||||||
|
'## ' + ctx.settings.greeting,
|
||||||
|
'',
|
||||||
|
'- before: ' + before.map((line) => line.text).join('|'),
|
||||||
|
'- current: **' + (current ? current.text : '') + '**',
|
||||||
|
'- after: ' + after.map((line) => line.text).join('|'),
|
||||||
|
'- exec: ' + result.stdout,
|
||||||
|
].join('\\n'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PROBE_PRELOAD = `
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
contextBridge.exposeInMainWorld('probe', { info: () => ipcRenderer.invoke('e2e-probe:info') });
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PROBE_WINDOW_HTML = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>E2E Probe Window</title></head>
|
||||||
|
<body>
|
||||||
|
<pre id="out">loading</pre>
|
||||||
|
<script>
|
||||||
|
window.probe.info().then((info) => {
|
||||||
|
document.getElementById('out').textContent = JSON.stringify(info);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Started = { session: E2eSession; overlay: CdpPage };
|
||||||
|
let started: Promise<Started> | undefined;
|
||||||
|
|
||||||
|
function useSession(): Promise<Started> {
|
||||||
|
started ??= (async () => {
|
||||||
|
const session = await startE2eSession({
|
||||||
|
config: { plugins: { 'e2e-probe': { enabled: true, settings: { greeting: 'hello' } } } },
|
||||||
|
files: {
|
||||||
|
'plugins/e2e-probe/plugin.json': JSON.stringify({ name: 'E2E Probe', main: 'main.cjs' }),
|
||||||
|
'plugins/e2e-probe/main.cjs': PROBE_PLUGIN,
|
||||||
|
'plugins/e2e-probe/preload.js': PROBE_PRELOAD,
|
||||||
|
'plugins/e2e-probe/ui/index.html': PROBE_WINDOW_HTML,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
||||||
|
} catch (error) {
|
||||||
|
await session.dispose();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
const running = await started?.catch(() => undefined);
|
||||||
|
await running?.session.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
type PanelState = {
|
||||||
|
title: string;
|
||||||
|
heading: string;
|
||||||
|
current: string;
|
||||||
|
items: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a plugin action opens the plugin panel with subtitle context and child process output',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
const [previous, cue, next] = FIXTURE_CUES;
|
||||||
|
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||||
|
{ description: `overlay to show "${cue.text}"` },
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.runAppCommand(
|
||||||
|
'--session-action',
|
||||||
|
JSON.stringify({
|
||||||
|
actionId: 'runPluginAction',
|
||||||
|
payload: { pluginActionId: 'e2e-probe/show' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const modal = await session.openPage('renderer/index.html?layer=modal');
|
||||||
|
const panel = await modal
|
||||||
|
.waitFor<PanelState | false>(
|
||||||
|
`(() => {
|
||||||
|
const root = document.getElementById('pluginPanelModal');
|
||||||
|
const body = document.getElementById('pluginPanelBody');
|
||||||
|
if (root.classList.contains('hidden') || !/exec[: ]/.test(body.textContent)) return false;
|
||||||
|
return {
|
||||||
|
title: document.getElementById('pluginPanelTitle').textContent,
|
||||||
|
heading: body.querySelector('h4')?.textContent ?? '',
|
||||||
|
current: body.querySelector('strong')?.textContent ?? '',
|
||||||
|
items: Array.from(body.querySelectorAll('li'), (item) => item.textContent),
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
{ description: 'plugin panel to show the finished probe result' },
|
||||||
|
)
|
||||||
|
.catch(async (error: unknown) => {
|
||||||
|
const body = await modal.evaluate<string>(
|
||||||
|
`document.getElementById('pluginPanelBody').textContent`,
|
||||||
|
);
|
||||||
|
throw new Error(`Plugin panel body: ${JSON.stringify(body)}`, { cause: error });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(panel, {
|
||||||
|
title: 'Probe',
|
||||||
|
heading: 'hello',
|
||||||
|
current: cue.text,
|
||||||
|
items: [
|
||||||
|
`before: ${previous.text}`,
|
||||||
|
`current: ${cue.text}`,
|
||||||
|
`after: ${next.text}`,
|
||||||
|
'exec: child-ok',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closing hides the modal window (Linux destroys it), often before Chromium
|
||||||
|
// acknowledges the key event, so the send is not awaited. The plugin's log line
|
||||||
|
// proves the close reached main and aborted the handle.
|
||||||
|
void modal
|
||||||
|
.send('Input.dispatchKeyEvent', {
|
||||||
|
type: 'rawKeyDown',
|
||||||
|
key: 'Escape',
|
||||||
|
code: 'Escape',
|
||||||
|
windowsVirtualKeyCode: 27,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
await waitUntil(
|
||||||
|
() =>
|
||||||
|
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe panel aborted'),
|
||||||
|
{ description: 'closing the panel to abort the plugin handle in main' },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a CLI plugin action opens a plugin window whose preload talks to the plugin',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session } = await useSession();
|
||||||
|
|
||||||
|
await session.runAppCommand('--plugin-action', 'e2e-probe/window');
|
||||||
|
|
||||||
|
const page = await session.openPage('plugins/e2e-probe/ui/index.html');
|
||||||
|
const info = await page.waitFor<{ dataDir: string; greeting: string } | false>(
|
||||||
|
`(() => {
|
||||||
|
const text = document.getElementById('out')?.textContent;
|
||||||
|
return text && text !== 'loading' ? JSON.parse(text) : false;
|
||||||
|
})()`,
|
||||||
|
{ description: 'plugin window to read plugin info over IPC' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(info.greeting, 'hello');
|
||||||
|
assert.equal(path.basename(info.dataDir), 'e2e-probe');
|
||||||
|
assert.equal(path.basename(path.dirname(info.dataDir)), 'plugin-data');
|
||||||
|
assert.equal(fs.statSync(info.dataDir).isDirectory(), true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async () => {
|
||||||
|
const { session } = await useSession();
|
||||||
|
const mediaPath = await session.mpv.command('get_property', 'path');
|
||||||
|
|
||||||
|
// Replacing the playing file ends it with reason "stop".
|
||||||
|
await session.mpv.command('loadfile', String(mediaPath), 'replace');
|
||||||
|
|
||||||
|
await waitUntil(
|
||||||
|
() =>
|
||||||
|
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe end-file stop'),
|
||||||
|
{ description: 'the plugin to log the end-file reason' },
|
||||||
|
);
|
||||||
|
// Later tests need the replacement loaded and paused: a sub-add during the reload lands on
|
||||||
|
// the outgoing file, and the app's autoplay resume would keep cues moving under them.
|
||||||
|
await session.settleAfterResume();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'plugin subtitle lookup uses the subtitle clock for repeated retimed lines',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
// Unlike any fixture cue, so the overlay showing it proves main received this line rather
|
||||||
|
// than still displaying one from an earlier test.
|
||||||
|
const text = '今日は猫が好きです。';
|
||||||
|
const subtitlePath = path.join(session.root, 'repeated.srt');
|
||||||
|
fs.writeFileSync(
|
||||||
|
subtitlePath,
|
||||||
|
`1\n00:00:01,000 --> 00:00:03,000\n${text}\n\n2\n00:00:06,000 --> 00:00:08,000\n${text}\n`,
|
||||||
|
);
|
||||||
|
const originalSid = await session.mpv.command('get_property', 'sid');
|
||||||
|
// Runs the probe action and returns the cue it logged on its nth run.
|
||||||
|
const reportSubtitle = async (run: number): Promise<unknown> => {
|
||||||
|
await session.runAppCommand('--plugin-action', 'e2e-probe/subtitle');
|
||||||
|
const line = await waitUntil(
|
||||||
|
() =>
|
||||||
|
fs
|
||||||
|
.readFileSync(path.join(session.root, 'app.log'), 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.filter((entry) => entry.includes('probe subtitle '))[run - 1],
|
||||||
|
{ description: `the plugin to report its current subtitle (run ${run})` },
|
||||||
|
);
|
||||||
|
assert.ok(line);
|
||||||
|
return JSON.parse(line.slice(line.indexOf('probe subtitle ') + 'probe subtitle '.length));
|
||||||
|
};
|
||||||
|
const seekToCue = async (time: number, cueIndex: number): Promise<void> => {
|
||||||
|
await session.mpv.command('seek', time, 'absolute+exact');
|
||||||
|
// time-pos can activate the sidebar while mpv is still rendering the seek with
|
||||||
|
// sub-text cleared. Wait for the seek, then for main to receive the live text.
|
||||||
|
await waitUntil(
|
||||||
|
async () => (await session.mpv.command('get_property', 'seeking')) === false,
|
||||||
|
{
|
||||||
|
description: `the seek to ${time}s to finish`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await overlay.waitFor(
|
||||||
|
`(async () => {
|
||||||
|
const text = await window.electronAPI.getCurrentSubtitleRaw();
|
||||||
|
return text === ${JSON.stringify(text)} &&
|
||||||
|
document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item')[${cueIndex}]?.classList.contains('active') === true;
|
||||||
|
})()`,
|
||||||
|
{ description: `delayed cue ${cueIndex + 1} to be active with live subtitle text` },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await session.mpv.command('seek', 0, 'absolute+exact');
|
||||||
|
await session.mpv.command('sub-add', subtitlePath, 'select');
|
||||||
|
await session.mpv.command('set_property', 'sub-delay', 5);
|
||||||
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === 2`,
|
||||||
|
{ description: 'the repeated subtitle cues to be parsed' },
|
||||||
|
);
|
||||||
|
await seekToCue(7, 0);
|
||||||
|
assert.deepEqual(await reportSubtitle(1), { text, startTime: 1, endTime: 3 });
|
||||||
|
|
||||||
|
// Same text again at 6-8 on the subtitle clock, so only the timings tell the cues apart.
|
||||||
|
await seekToCue(12, 1);
|
||||||
|
assert.deepEqual(await reportSubtitle(2), { text, startTime: 6, endTime: 8 });
|
||||||
|
} finally {
|
||||||
|
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||||
|
await session.mpv.command('set_property', 'sid', originalSid);
|
||||||
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'plugin requests report command failures and mined notes carry the captured source',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
const cue = FIXTURE_CUES[1];
|
||||||
|
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||||
|
{ description: 'the mined subtitle to be visible' },
|
||||||
|
);
|
||||||
|
await session.runAppCommand('--plugin-action', 'e2e-probe/request');
|
||||||
|
await waitUntil(
|
||||||
|
() =>
|
||||||
|
fs
|
||||||
|
.readFileSync(path.join(session.root, 'app.log'), 'utf8')
|
||||||
|
.includes('probe request rejected'),
|
||||||
|
{ description: 'mpv to reject an invalid plugin request' },
|
||||||
|
);
|
||||||
|
await session.runAppCommand('--mine-sentence');
|
||||||
|
const log = await waitUntil(
|
||||||
|
() => {
|
||||||
|
const text = fs.readFileSync(path.join(session.root, 'app.log'), 'utf8');
|
||||||
|
return text.includes('probe mined ') ? text : null;
|
||||||
|
},
|
||||||
|
{ description: 'the mining event to reach the plugin' },
|
||||||
|
);
|
||||||
|
assert.ok(log);
|
||||||
|
const line = log.split('\n').find((entry) => entry.includes('probe mined '));
|
||||||
|
assert.ok(line);
|
||||||
|
const notes: unknown = JSON.parse(
|
||||||
|
line.slice(line.indexOf('probe mined ') + 'probe mined '.length),
|
||||||
|
);
|
||||||
|
assert.ok(Array.isArray(notes));
|
||||||
|
assert.equal(notes.length, 1);
|
||||||
|
const note: unknown = notes[0];
|
||||||
|
assert.partialDeepStrictEqual(note, {
|
||||||
|
provenance: {
|
||||||
|
subtitle: { text: cue.text },
|
||||||
|
media: { path: await session.mpv.command('get_property', 'path') },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(note && typeof note === 'object' && 'originalNoteId' in note && 'noteId' in note);
|
||||||
|
assert.equal(note.originalNoteId, note.noteId);
|
||||||
|
},
|
||||||
|
);
|
||||||
+25
-26
@@ -172,33 +172,30 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('runAppCommandCaptureOutput transports Linux AppImage args through environment', async () => {
|
||||||
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
|
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
|
||||||
async () => {
|
if (process.platform !== 'linux') return;
|
||||||
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
|
await withTempCase(({ dir }) => {
|
||||||
if (process.platform !== 'linux') return;
|
const appPath = writeFakeApp(
|
||||||
await withTempCase(({ dir }) => {
|
dir,
|
||||||
const appPath = writeFakeApp(
|
[
|
||||||
dir,
|
'printf "args:%s\\n" "$*"',
|
||||||
[
|
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
|
||||||
'printf "args:%s\\n" "$*"',
|
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
|
||||||
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
|
'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
|
||||||
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
|
],
|
||||||
'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
|
'SubMiner.AppImage',
|
||||||
],
|
);
|
||||||
'SubMiner.AppImage',
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
|
const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.match(result.stdout, /^args:\n/m);
|
assert.match(result.stdout, /^args:\n/m);
|
||||||
assert.match(result.stdout, /^argc:2\n/m);
|
assert.match(result.stdout, /^argc:2\n/m);
|
||||||
assert.match(result.stdout, /^arg0:--app-ping\n/m);
|
assert.match(result.stdout, /^arg0:--app-ping\n/m);
|
||||||
assert.match(result.stdout, /^arg1:--socket\n/m);
|
assert.match(result.stdout, /^arg1:--socket\n/m);
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', async () => {
|
test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', async () => {
|
||||||
await withTempCase(({ dir }) => {
|
await withTempCase(({ dir }) => {
|
||||||
@@ -546,8 +543,10 @@ test('launchAppBackgroundDetached starts background child directly', async () =>
|
|||||||
|
|
||||||
launchAppBackgroundDetached(appPath, 'info');
|
launchAppBackgroundDetached(appPath, 'info');
|
||||||
|
|
||||||
|
// The shell creates each file before writing to it, so wait for content, not existence.
|
||||||
|
const hasContent = (file: string) => fs.existsSync(file) && fs.statSync(file).size > 0;
|
||||||
const deadline = Date.now() + 1000;
|
const deadline = Date.now() + 1000;
|
||||||
while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
|
while ((!hasContent(argsPath) || !hasContent(envPath)) && Date.now() < deadline) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.21.1",
|
"version": "0.22.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",
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
"brace-expansion": "5.0.11",
|
"brace-expansion": "5.0.11",
|
||||||
"electron-builder-squirrel-windows": "26.16.1",
|
"electron-builder-squirrel-windows": "26.16.1",
|
||||||
"fast-uri": "3.1.7",
|
"fast-uri": "3.1.7",
|
||||||
"follow-redirects": "1.16.1",
|
"follow-redirects": "1.16.2",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.2",
|
"js-yaml": "4.3.2",
|
||||||
@@ -185,6 +185,10 @@
|
|||||||
{
|
{
|
||||||
"from": "dist/scripts/get-mpv-window-macos",
|
"from": "dist/scripts/get-mpv-window-macos",
|
||||||
"to": "scripts/get-mpv-window-macos"
|
"to": "scripts/get-mpv-window-macos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "dist/scripts/macos-controller-watch",
|
||||||
|
"to": "scripts/macos-controller-watch"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
@@ -283,8 +287,5 @@
|
|||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all"
|
"trailingComma": "all"
|
||||||
},
|
|
||||||
"patchedDependencies": {
|
|
||||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,73 @@
|
|||||||
## Highlights
|
## Highlights
|
||||||
|
### Added
|
||||||
|
- **Plugins**:
|
||||||
|
- JavaScript plugins load from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
|
||||||
|
- Plugins can react to mpv events and mined cards, call AnkiConnect, run programs, and show results in an overlay panel or window.
|
||||||
|
- Three examples are included: `ai-explain` for line explanations, `anki-sync` for AnkiWeb syncing, and `episode-tags` for tagging cards by show and episode.
|
||||||
|
- **Subtitle Filters**: Remove parenthesized text, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
|
||||||
|
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be fully operated by controller and show button hints. Remap both layouts per controller in `Alt+C`.
|
||||||
|
- **Controller Customization**:
|
||||||
|
- `Alt+C` actions can be combos: click **Learn**, hold one button, and press another.
|
||||||
|
- New unbound actions cover subtitle navigation, holding to show the secondary subtitle, Yomitan entry selection, and character navigation.
|
||||||
|
- **Button Style** offers Xbox, Nintendo, or PlayStation names and symbols, saved per controller.
|
||||||
|
- `Alt+C` now warns when two actions share an input.
|
||||||
|
- **Keyboard-Only Mode**: Press `C` to move and look up by character, reaching words split by subtitle tokenization. **Pause Video In Keyboard-Only Mode** can also pause when the mode turns on and resume when it turns off.
|
||||||
|
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Playback resumes when the modal closes.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Stats Session View**: The expanded session view in the stats dashboard has been redesigned. It shows stat tiles for pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words with lookup and card-mined markers.
|
- **Stats Trends**: Rebuilt with period comparisons, Learning charts, a heatmap, a title timeline, and a single running-total chart. Week grouping, custom ranges, a page-wide title filter, and click-through to sessions and library pages are also new.
|
||||||
|
- **Stats Vocabulary**:
|
||||||
|
- Rebuilt around one searchable, sortable word table with Worth mining, All words, Across titles, and Newest views.
|
||||||
|
- Added known words by frequency band, a vocabulary growth chart, and a full kanji view.
|
||||||
|
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
|
||||||
|
- Words and lines from streams now say they can't be mined, instead of showing Unavailable buttons.
|
||||||
|
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Controller connected and disabled messages now follow your regular notification setting instead of using a separate overlay pill.
|
||||||
|
- **Keyboard-Only Mode Exit**: Turning the mode off now also closes the dictionary popup.
|
||||||
|
- **Hachidori**: The bundled dictionary reader is updated to 0.4.0, adding every pitch accent in each popup entry, pitch drawn on the headword reading, per-kanji furigana, and Yomitan-style definition tags.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Subtitle Delay**: Retiming subtitles in mpv (z/x or sub-step) now carries through SubMiner. Mined card audio, images, and timestamps match the retimed line, and so do the subtitle sidebar, subtitle navigation, and replay.
|
- **Controller Settings (`Alt+C`)**:
|
||||||
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
|
- Remaps now apply right away, without restarting SubMiner.
|
||||||
|
- Editing stays locked until you press a button, so the window can't edit a controller it can't see.
|
||||||
|
- With several controllers connected, it selects and listens to the one you just pressed.
|
||||||
|
- **Learn**, **Clear**, and **Reset** are reachable again, D-pad **Learn** no longer errors, and button names are correct on standard-layout pads.
|
||||||
|
- **Controller Input**: One press no longer runs its action twice on macOS and Windows. On macOS, buttons now work while mpv is in front.
|
||||||
|
- **Dictionary Popup**: Controller and keyboard lookups pause and resume playback reliably, and moving between words right after closing the popup no longer reopens it.
|
||||||
|
- **Anki Sentence Audio**: `ankiConnect.fields.audio` now defaults to `SentenceAudio`, so generated audio no longer overwrites Yomitan's word audio. If you relied on the old default, set it to `ExpressionAudio`.
|
||||||
|
- **AniList**: Progress sync now adds an exactly matched or pinned show to your Watching or Completed list when it isn't on your list yet. Completed, Paused, Dropped, and Rewatching entries are never changed.
|
||||||
|
- **Stats Dashboard**:
|
||||||
|
- Overview, media, library, trends, vocabulary, and sentence search load several times faster on large databases.
|
||||||
|
- Trends totals, episode counts, rate charts, axis labels, and date handling in time zones ahead of UTC are corrected.
|
||||||
|
- Word lists now total every form of a word and show the dictionary form's reading.
|
||||||
|
- Names no longer slip past Hide Names, and totals stay correct after vocabulary cleanup.
|
||||||
|
- **Playback**: Reloading the current file in mpv no longer leaves playback paused, and the "Overlay loading" message no longer gets stuck when moving to the next playlist file.
|
||||||
|
- **AppImage**: "Service Crash" notifications on quit no longer appear on Ubuntu 26.04 and newer.
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
- **README**: Refreshed the anime screenshots using the current build.
|
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
|
- feat(plugins): add JavaScript plugin system with overlay panel by @ksyasuda in #303
|
||||||
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
|
- fix(anilist): add missing list entry on confident match by @dandascan in #305
|
||||||
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
|
- Controller: hold to show the secondary subtitle, dictionary entry navigation, standard button names by @YashaNoos in #312
|
||||||
|
- Controller: layouts for the media timing review and duplicate card dialogs by @YashaNoos in #313
|
||||||
|
- Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes by @YashaNoos in #316
|
||||||
|
- fix(plugin): keep loading OSD off on warm playlist advance by @ksyasuda in #317
|
||||||
|
- feat(subtitles): add regex subtitle filters with built-in presets by @ksyasuda in #318
|
||||||
|
- feat(jimaku): auto-open modal when a video has no Japanese subtitle by @dandascan in #319
|
||||||
|
- fix(controller): pick up macOS presses while mpv is in front by @ksyasuda in #321
|
||||||
|
- perf(stats): speed up dashboard queries on large databases by @ksyasuda in #322
|
||||||
|
- fix(mpv): resume playback after a same-file reload that skips the path clear by @ksyasuda in #323
|
||||||
|
- fix(controller): keep popup pause and press handling reliable by @ksyasuda in #324
|
||||||
|
- feat(stats): overhaul the Trends tab by @ksyasuda in #325
|
||||||
|
- Controller: character navigation in keyboard-only mode, plain Xbox face names by @YashaNoos in #326
|
||||||
|
- feat(controller): add hold + press combos to main bindings by @ksyasuda in #328
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
|
||||||
|
- @dandascan made their first contribution in #305
|
||||||
|
- @YashaNoos made their first contribution in #312
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
},
|
|
||||||
@@ -162,12 +162,15 @@ function M.create(ctx)
|
|||||||
and not (state.overlay_running and state.auto_play_ready_signal_seen == true)
|
and not (state.overlay_running and state.auto_play_ready_signal_seen == true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A warm playlist advance keeps the already-ready overlay window, and the app only reports
|
||||||
|
-- readiness when content first loads, so a restarted spinner would never clear (#315).
|
||||||
local function should_show_overlay_loading_osd()
|
local function should_show_overlay_loading_osd()
|
||||||
return (
|
return (
|
||||||
resolve_overlay_loading_osd_enabled()
|
resolve_overlay_loading_osd_enabled()
|
||||||
or (resolve_auto_start_enabled() and resolve_auto_start_visible_overlay_enabled())
|
or (resolve_auto_start_enabled() and resolve_auto_start_visible_overlay_enabled())
|
||||||
)
|
)
|
||||||
and not state.suppress_ready_overlay_restore
|
and not state.suppress_ready_overlay_restore
|
||||||
|
and not (state.overlay_running and state.overlay_content_ready_seen == true)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
|
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
|
||||||
@@ -311,6 +314,7 @@ function M.create(ctx)
|
|||||||
process.stop_overlay_loading_osd()
|
process.stop_overlay_loading_osd()
|
||||||
clear_pending_visible_overlay_hide()
|
clear_pending_visible_overlay_hide()
|
||||||
state.auto_play_ready_signal_seen = false
|
state.auto_play_ready_signal_seen = false
|
||||||
|
state.overlay_content_ready_seen = false
|
||||||
state.current_media_identity = nil
|
state.current_media_identity = nil
|
||||||
state.current_media_title = nil
|
state.current_media_title = nil
|
||||||
state.pending_reload_media_identity = nil
|
state.pending_reload_media_identity = nil
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ function M.create(ctx)
|
|||||||
process.hold_auto_play_ready_gate()
|
process.hold_auto_play_ready_gate()
|
||||||
end)
|
end)
|
||||||
mp.register_script_message("subminer-overlay-loading-ready", function()
|
mp.register_script_message("subminer-overlay-loading-ready", function()
|
||||||
process.stop_overlay_loading_osd()
|
process.mark_overlay_content_ready()
|
||||||
|
end)
|
||||||
|
-- Sent when the app dismisses loading while its overlay window has no content (hidden mid-load,
|
||||||
|
-- or a replaced window).
|
||||||
|
mp.register_script_message("subminer-overlay-loading-dismissed", function()
|
||||||
|
process.mark_overlay_content_not_ready()
|
||||||
end)
|
end)
|
||||||
mp.register_script_message("subminer-stats-toggle", function()
|
mp.register_script_message("subminer-stats-toggle", function()
|
||||||
if opts.osd_messages then
|
if opts.osd_messages then
|
||||||
|
|||||||
@@ -282,6 +282,19 @@ function M.create(ctx)
|
|||||||
clear_overlay_loading_osd_deadline()
|
clear_overlay_loading_osd_deadline()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Handles the app's subminer-overlay-loading-ready message.
|
||||||
|
local function mark_overlay_content_ready()
|
||||||
|
state.overlay_content_ready_seen = true
|
||||||
|
stop_overlay_loading_osd()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handles subminer-overlay-loading-dismissed: the app's current overlay window has no content
|
||||||
|
-- (hidden mid-load, or a replaced window), so earlier readiness no longer applies.
|
||||||
|
local function mark_overlay_content_not_ready()
|
||||||
|
state.overlay_content_ready_seen = false
|
||||||
|
stop_overlay_loading_osd()
|
||||||
|
end
|
||||||
|
|
||||||
local function start_overlay_loading_osd()
|
local function start_overlay_loading_osd()
|
||||||
if state.overlay_loading_osd_active then
|
if state.overlay_loading_osd_active then
|
||||||
return
|
return
|
||||||
@@ -741,6 +754,7 @@ function M.create(ctx)
|
|||||||
|
|
||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
state.auto_play_ready_signal_seen = false
|
state.auto_play_ready_signal_seen = false
|
||||||
|
state.overlay_content_ready_seen = false
|
||||||
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
|
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
|
||||||
stop_overlay_loading_osd()
|
stop_overlay_loading_osd()
|
||||||
show_osd("Overlay start failed")
|
show_osd("Overlay start failed")
|
||||||
@@ -795,6 +809,7 @@ function M.create(ctx)
|
|||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
state.texthooker_running = false
|
state.texthooker_running = false
|
||||||
state.auto_play_ready_signal_seen = false
|
state.auto_play_ready_signal_seen = false
|
||||||
|
state.overlay_content_ready_seen = false
|
||||||
stop_overlay_loading_osd()
|
stop_overlay_loading_osd()
|
||||||
disarm_auto_play_ready_gate()
|
disarm_auto_play_ready_gate()
|
||||||
show_osd("Stopped")
|
show_osd("Stopped")
|
||||||
@@ -934,6 +949,7 @@ function M.create(ctx)
|
|||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
state.texthooker_running = false
|
state.texthooker_running = false
|
||||||
state.auto_play_ready_signal_seen = false
|
state.auto_play_ready_signal_seen = false
|
||||||
|
state.overlay_content_ready_seen = false
|
||||||
state.suppress_ready_overlay_restore = false
|
state.suppress_ready_overlay_restore = false
|
||||||
state.force_ready_overlay_restore = true
|
state.force_ready_overlay_restore = true
|
||||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||||
@@ -957,6 +973,7 @@ function M.create(ctx)
|
|||||||
if not success or (result and result.status ~= 0) then
|
if not success or (result and result.status ~= 0) then
|
||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
state.auto_play_ready_signal_seen = false
|
state.auto_play_ready_signal_seen = false
|
||||||
|
state.overlay_content_ready_seen = false
|
||||||
subminer_log(
|
subminer_log(
|
||||||
"error",
|
"error",
|
||||||
"process",
|
"process",
|
||||||
@@ -1035,6 +1052,8 @@ function M.create(ctx)
|
|||||||
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
|
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
|
||||||
start_overlay_loading_osd = start_overlay_loading_osd,
|
start_overlay_loading_osd = start_overlay_loading_osd,
|
||||||
stop_overlay_loading_osd = stop_overlay_loading_osd,
|
stop_overlay_loading_osd = stop_overlay_loading_osd,
|
||||||
|
mark_overlay_content_ready = mark_overlay_content_ready,
|
||||||
|
mark_overlay_content_not_ready = mark_overlay_content_not_ready,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ function M.new()
|
|||||||
auto_play_ready_osd_timer = nil,
|
auto_play_ready_osd_timer = nil,
|
||||||
auto_play_ready_signal_seen = false,
|
auto_play_ready_signal_seen = false,
|
||||||
auto_play_ready_initial_pause_ownership_consumed = false,
|
auto_play_ready_initial_pause_ownership_consumed = false,
|
||||||
|
-- The running overlay has reported its content ready at least once; warm playlist advances
|
||||||
|
-- reuse that window, so they must not restart the loading spinner (#315).
|
||||||
|
overlay_content_ready_seen = false,
|
||||||
overlay_loading_osd_active = false,
|
overlay_loading_osd_active = false,
|
||||||
overlay_loading_osd_timer = nil,
|
overlay_loading_osd_timer = nil,
|
||||||
overlay_loading_osd_deadline = nil,
|
overlay_loading_osd_deadline = nil,
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ test.each([false, true])(
|
|||||||
port: 0,
|
port: 0,
|
||||||
fetch(request) {
|
fetch(request) {
|
||||||
const pathname = new URL(request.url).pathname;
|
const pathname = new URL(request.url).pathname;
|
||||||
|
// Ignore stray local probes of the ephemeral port; only count the script's downloads.
|
||||||
|
if (!pathname.startsWith('/ksyasuda/SubMiner/releases/download/')) {
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}
|
||||||
requests.push(pathname);
|
requests.push(pathname);
|
||||||
if (unavailable || requests.length === 1) return new Response('try again', { status: 503 });
|
if (unavailable || requests.length === 1) return new Response('try again', { status: 503 });
|
||||||
const name = pathname.split('/').at(-1);
|
const name = pathname.split('/').at(-1);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const http = require('node:http');
|
|||||||
const root = path.resolve(__dirname, '..');
|
const root = path.resolve(__dirname, '..');
|
||||||
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
|
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
|
||||||
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
|
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
|
||||||
|
const { HACHIDORI_POPUP_OPEN_SELECTOR } = require(
|
||||||
|
path.join(root, 'dist/renderer/yomitan-popup.js'),
|
||||||
|
);
|
||||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
|
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
@@ -43,6 +46,58 @@ function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') {
|
|||||||
return zipPath;
|
return zipPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The renderer's popup auto-pause and key routing read this marker because the
|
||||||
|
// reader's shadow root is closed. Drives the real reader with real input, since
|
||||||
|
// stubbed DOM tests cannot notice an upstream merge that changes what it exposes.
|
||||||
|
async function assertPopupOpenMarkerFollowsPane(targetSession) {
|
||||||
|
const server = http.createServer((_request, response) => {
|
||||||
|
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
response.end(
|
||||||
|
'<!doctype html><p id="word" style="font-size:48px;margin:300px 0 0 100px">食べた</p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const page = new BrowserWindow({
|
||||||
|
width: 900,
|
||||||
|
height: 600,
|
||||||
|
show: true,
|
||||||
|
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await page.loadURL(`http://127.0.0.1:${server.address().port}/`);
|
||||||
|
const { webContents } = page;
|
||||||
|
const word = await webContents.executeJavaScript(
|
||||||
|
`(() => { const { x, y, height } = document.getElementById('word').getBoundingClientRect(); return { x, y, height }; })()`,
|
||||||
|
);
|
||||||
|
const repeatUntilMarker = async (expected, act) => {
|
||||||
|
const query = `document.querySelector(${JSON.stringify(HACHIDORI_POPUP_OPEN_SELECTOR)}) !== null`;
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
// Content scripts load asynchronously, so keep acting until the reader reacts.
|
||||||
|
act(attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
if ((await webContents.executeJavaScript(query)) === expected) return;
|
||||||
|
}
|
||||||
|
assert.fail(`Hachidori popup-open marker never became ${expected}`);
|
||||||
|
};
|
||||||
|
page.focus();
|
||||||
|
webContents.focus();
|
||||||
|
await repeatUntilMarker(true, (attempt) =>
|
||||||
|
webContents.sendInputEvent({
|
||||||
|
type: 'mouseMove',
|
||||||
|
x: Math.round(word.x + 24) + (attempt % 2),
|
||||||
|
y: Math.round(word.y + word.height / 2),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await repeatUntilMarker(false, () => {
|
||||||
|
webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||||
|
webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
page.destroy();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -211,6 +266,7 @@ app
|
|||||||
assert.deepEqual(unmatched, []);
|
assert.deepEqual(unmatched, []);
|
||||||
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
|
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
|
||||||
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
|
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
|
||||||
|
await assertPopupOpenMarkerFollowsPane(targetSession);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
|
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const path = require('node:path');
|
|||||||
|
|
||||||
const LINUX_FFMPEG_LIBRARY = 'libffmpeg.so';
|
const LINUX_FFMPEG_LIBRARY = 'libffmpeg.so';
|
||||||
const MACOS_WINDOW_HELPER = 'get-mpv-window-macos';
|
const MACOS_WINDOW_HELPER = 'get-mpv-window-macos';
|
||||||
|
const MACOS_CONTROLLER_HELPER = 'macos-controller-watch';
|
||||||
|
const MACOS_HELPERS = [MACOS_WINDOW_HELPER, MACOS_CONTROLLER_HELPER];
|
||||||
const DEFAULT_MACOS_APP_BUNDLE = 'SubMiner.app';
|
const DEFAULT_MACOS_APP_BUNDLE = 'SubMiner.app';
|
||||||
|
|
||||||
function resolveMacOSAppBundlePath(context) {
|
function resolveMacOSAppBundlePath(context) {
|
||||||
@@ -52,7 +54,7 @@ async function stageLinuxAppImageSharedLibrary(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyMacOSWindowHelper(
|
async function verifyMacOSHelpers(
|
||||||
context,
|
context,
|
||||||
deps = {
|
deps = {
|
||||||
access: (filePath, mode) => fs.access(filePath, mode),
|
access: (filePath, mode) => fs.access(filePath, mode),
|
||||||
@@ -60,27 +62,28 @@ async function verifyMacOSWindowHelper(
|
|||||||
) {
|
) {
|
||||||
if (context.electronPlatformName !== 'darwin') return false;
|
if (context.electronPlatformName !== 'darwin') return false;
|
||||||
|
|
||||||
const appBundlePath = resolveMacOSAppBundlePath(context);
|
const scriptsDir = path.join(
|
||||||
const helperPath = path.join(
|
resolveMacOSAppBundlePath(context),
|
||||||
appBundlePath,
|
|
||||||
'Contents',
|
'Contents',
|
||||||
'Resources',
|
'Resources',
|
||||||
'scripts',
|
'scripts',
|
||||||
MACOS_WINDOW_HELPER,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
for (const helperName of MACOS_HELPERS) {
|
||||||
await deps.access(helperPath, fsSync.constants.X_OK);
|
const helperPath = path.join(scriptsDir, helperName);
|
||||||
} catch (error) {
|
try {
|
||||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
await deps.access(helperPath, fsSync.constants.X_OK);
|
||||||
throw new Error(
|
} catch (error) {
|
||||||
`macOS packaging requires ${MACOS_WINDOW_HELPER} at ${helperPath}. Run bun run build:assets on macOS with swiftc available before packaging.`,
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||||
);
|
throw new Error(
|
||||||
|
`macOS packaging requires ${helperName} at ${helperPath}. Run bun run build:assets on macOS with swiftc available before packaging.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error && typeof error === 'object' && error.code === 'EACCES') {
|
||||||
|
throw new Error(`macOS helper is not executable: ${helperPath}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
if (error && typeof error === 'object' && error.code === 'EACCES') {
|
|
||||||
throw new Error(`macOS window helper is not executable: ${helperPath}`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -103,17 +106,17 @@ async function stageBundledBunRuntime(context, deps = {}) {
|
|||||||
|
|
||||||
async function afterPack(context, deps = {}) {
|
async function afterPack(context, deps = {}) {
|
||||||
await stageLinuxAppImageSharedLibrary(context);
|
await stageLinuxAppImageSharedLibrary(context);
|
||||||
await verifyMacOSWindowHelper(context);
|
await verifyMacOSHelpers(context);
|
||||||
await stageBundledBunRuntime(context, deps);
|
await stageBundledBunRuntime(context, deps);
|
||||||
await (deps.auditPackage ?? require('./package-audit.cjs').auditPackage)(context);
|
await (deps.auditPackage ?? require('./package-audit.cjs').auditPackage)(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
LINUX_FFMPEG_LIBRARY,
|
LINUX_FFMPEG_LIBRARY,
|
||||||
MACOS_WINDOW_HELPER,
|
MACOS_HELPERS,
|
||||||
resolveMacOSAppBundlePath,
|
resolveMacOSAppBundlePath,
|
||||||
stageBundledBunRuntime,
|
stageBundledBunRuntime,
|
||||||
stageLinuxAppImageSharedLibrary,
|
stageLinuxAppImageSharedLibrary,
|
||||||
verifyMacOSWindowHelper,
|
verifyMacOSHelpers,
|
||||||
default: afterPack,
|
default: afterPack,
|
||||||
};
|
};
|
||||||
@@ -6,13 +6,13 @@ import test from 'node:test';
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
LINUX_FFMPEG_LIBRARY,
|
LINUX_FFMPEG_LIBRARY,
|
||||||
MACOS_WINDOW_HELPER,
|
MACOS_HELPERS,
|
||||||
default: afterPack,
|
default: afterPack,
|
||||||
stageLinuxAppImageSharedLibrary,
|
stageLinuxAppImageSharedLibrary,
|
||||||
verifyMacOSWindowHelper,
|
verifyMacOSHelpers,
|
||||||
} = require('./electron-builder-after-pack.cjs') as {
|
} = require('./electron-builder-after-pack.cjs') as {
|
||||||
LINUX_FFMPEG_LIBRARY: string;
|
LINUX_FFMPEG_LIBRARY: string;
|
||||||
MACOS_WINDOW_HELPER: string;
|
MACOS_HELPERS: string[];
|
||||||
default: (
|
default: (
|
||||||
context: {
|
context: {
|
||||||
appOutDir: string;
|
appOutDir: string;
|
||||||
@@ -34,7 +34,7 @@ const {
|
|||||||
appOutDir: string;
|
appOutDir: string;
|
||||||
electronPlatformName: string;
|
electronPlatformName: string;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
verifyMacOSWindowHelper: (context: {
|
verifyMacOSHelpers: (context: {
|
||||||
appOutDir: string;
|
appOutDir: string;
|
||||||
electronPlatformName: string;
|
electronPlatformName: string;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
@@ -88,24 +88,19 @@ test('stageLinuxAppImageSharedLibrary skips non-Linux packaging contexts', async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verifyMacOSWindowHelper accepts packaged macOS helper binary', async () => {
|
test('verifyMacOSHelpers accepts packaged macOS helper binaries', async () => {
|
||||||
const workspace = createWorkspace('subminer-after-pack-macos-helper');
|
const workspace = createWorkspace('subminer-after-pack-macos-helper');
|
||||||
const appOutDir = path.join(workspace, 'SubMiner-darwin-arm64');
|
const appOutDir = path.join(workspace, 'SubMiner-darwin-arm64');
|
||||||
const helperPath = path.join(
|
const scriptsDir = path.join(appOutDir, 'SubMiner.app', 'Contents', 'Resources', 'scripts');
|
||||||
appOutDir,
|
|
||||||
'SubMiner.app',
|
|
||||||
'Contents',
|
|
||||||
'Resources',
|
|
||||||
'scripts',
|
|
||||||
MACOS_WINDOW_HELPER,
|
|
||||||
);
|
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(helperPath), { recursive: true });
|
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||||
fs.writeFileSync(helperPath, 'compiled helper', 'utf8');
|
for (const helperName of MACOS_HELPERS) {
|
||||||
fs.chmodSync(helperPath, 0o755);
|
fs.writeFileSync(path.join(scriptsDir, helperName), 'compiled helper', 'utf8');
|
||||||
|
fs.chmodSync(path.join(scriptsDir, helperName), 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const verified = await verifyMacOSWindowHelper({
|
const verified = await verifyMacOSHelpers({
|
||||||
appOutDir,
|
appOutDir,
|
||||||
electronPlatformName: 'darwin',
|
electronPlatformName: 'darwin',
|
||||||
});
|
});
|
||||||
@@ -116,19 +111,22 @@ test('verifyMacOSWindowHelper accepts packaged macOS helper binary', async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verifyMacOSWindowHelper throws when macOS helper binary is missing', async () => {
|
test('verifyMacOSHelpers throws when a macOS helper binary is missing', async () => {
|
||||||
const workspace = createWorkspace('subminer-after-pack-macos-helper-missing');
|
const workspace = createWorkspace('subminer-after-pack-macos-helper-missing');
|
||||||
const appOutDir = path.join(workspace, 'SubMiner-darwin-arm64');
|
const appOutDir = path.join(workspace, 'SubMiner-darwin-arm64');
|
||||||
|
const scriptsDir = path.join(appOutDir, 'SubMiner.app', 'Contents', 'Resources', 'scripts');
|
||||||
|
|
||||||
fs.mkdirSync(path.join(appOutDir, 'SubMiner.app', 'Contents', 'Resources'), { recursive: true });
|
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(scriptsDir, 'get-mpv-window-macos'), 'compiled helper', 'utf8');
|
||||||
|
fs.chmodSync(path.join(scriptsDir, 'get-mpv-window-macos'), 0o755);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
verifyMacOSWindowHelper({
|
verifyMacOSHelpers({
|
||||||
appOutDir,
|
appOutDir,
|
||||||
electronPlatformName: 'darwin',
|
electronPlatformName: 'darwin',
|
||||||
}),
|
}),
|
||||||
/macOS packaging requires get-mpv-window-macos/,
|
/macOS packaging requires macos-controller-watch/,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
//
|
||||||
|
// macos-controller-watch.swift
|
||||||
|
// SubMiner - Report controller button presses on macOS while SubMiner is in the background
|
||||||
|
//
|
||||||
|
// macOS only delivers GameController input to the frontmost app, so the overlay sees no
|
||||||
|
// presses while mpv is in front. This helper opts in to background events and prints one
|
||||||
|
// line per press so SubMiner can focus the overlay and replay the press there.
|
||||||
|
//
|
||||||
|
// Usage: macos-controller-watch (long-running; exits when stdin closes)
|
||||||
|
// Output: "press <index> <connected pads>" per button press, using the W3C standard gamepad
|
||||||
|
// button indices. SubMiner only replays a press when one pad is connected: nothing identifies
|
||||||
|
// a pad to both GameController and Chromium, so with several it could hit the wrong one.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Cocoa
|
||||||
|
import Foundation
|
||||||
|
import GameController
|
||||||
|
|
||||||
|
private func emit(_ line: String) {
|
||||||
|
FileHandle.standardOutput.write(Data((line + "\n").utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard gamepad mapping (https://w3c.github.io/gamepad/#remapping), matching the
|
||||||
|
/// indices Chromium reports for the same pad.
|
||||||
|
private func standardButtons(_ pad: GCExtendedGamepad) -> [(Int, GCControllerButtonInput?)] {
|
||||||
|
return [
|
||||||
|
(0, pad.buttonA),
|
||||||
|
(1, pad.buttonB),
|
||||||
|
(2, pad.buttonX),
|
||||||
|
(3, pad.buttonY),
|
||||||
|
(4, pad.leftShoulder),
|
||||||
|
(5, pad.rightShoulder),
|
||||||
|
(6, pad.leftTrigger),
|
||||||
|
(7, pad.rightTrigger),
|
||||||
|
(8, pad.buttonOptions),
|
||||||
|
(9, pad.buttonMenu),
|
||||||
|
(10, pad.leftThumbstickButton),
|
||||||
|
(11, pad.rightThumbstickButton),
|
||||||
|
(12, pad.dpad.up),
|
||||||
|
(13, pad.dpad.down),
|
||||||
|
(14, pad.dpad.left),
|
||||||
|
(15, pad.dpad.right),
|
||||||
|
(16, pad.buttonHome),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectedPadCount() -> Int {
|
||||||
|
return GCController.controllers().filter { $0.extendedGamepad != nil }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func watch(_ controller: GCController) {
|
||||||
|
guard let pad = controller.extendedGamepad else { return }
|
||||||
|
for (index, button) in standardButtons(pad) {
|
||||||
|
button?.pressedChangedHandler = { _, _, pressed in
|
||||||
|
if pressed {
|
||||||
|
emit("press \(index) \(connectedPadCount())")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit with SubMiner: it holds our stdin open for as long as it runs.
|
||||||
|
private func exitWhenStdinCloses() {
|
||||||
|
Thread.detachNewThread {
|
||||||
|
while !FileHandle.standardInput.availableData.isEmpty {}
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
app.setActivationPolicy(.prohibited)
|
||||||
|
GCController.shouldMonitorBackgroundEvents = true
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .GCControllerDidConnect,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { notification in
|
||||||
|
if let controller = notification.object as? GCController {
|
||||||
|
watch(controller)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GCController.controllers().forEach(watch)
|
||||||
|
|
||||||
|
exitWhenStdinCloses()
|
||||||
|
app.run()
|
||||||
@@ -14,9 +14,8 @@ const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
|
|||||||
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
|
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
|
||||||
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
|
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
|
||||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
// Swift helpers compiled on macOS and shipped in Resources/scripts.
|
||||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
const MACOS_HELPERS = ['get-mpv-window-macos', 'macos-controller-watch'];
|
||||||
const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift');
|
|
||||||
|
|
||||||
function ensureDir(dirPath) {
|
function ensureDir(dirPath) {
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
@@ -45,9 +44,10 @@ function copySyncUiAssets() {
|
|||||||
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
|
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackToMacosSource() {
|
function fallbackToMacosSource(helperName) {
|
||||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
const sourceCopyPath = path.join(scriptsOutputDir, `${helperName}.swift`);
|
||||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
copyFile(path.join(scriptDir, `${helperName}.swift`), sourceCopyPath);
|
||||||
|
process.stdout.write(`Staged macOS helper source fallback: ${sourceCopyPath}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin the minimum macOS to the app's own floor (Electron's `minos`). Without an
|
// Pin the minimum macOS to the app's own floor (Electron's `minos`). Without an
|
||||||
@@ -64,34 +64,34 @@ function shouldSkipMacosHelperBuild() {
|
|||||||
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMacosHelper() {
|
function buildMacosHelper(helperName) {
|
||||||
if (shouldSkipMacosHelperBuild()) {
|
if (shouldSkipMacosHelperBuild()) {
|
||||||
process.stdout.write('Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)\n');
|
process.stdout.write('Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)\n');
|
||||||
fallbackToMacosSource();
|
fallbackToMacosSource(helperName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
process.stdout.write('Skipping macOS helper build (not on macOS)\n');
|
process.stdout.write('Skipping macOS helper build (not on macOS)\n');
|
||||||
fallbackToMacosSource();
|
fallbackToMacosSource(helperName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureDir(scriptsOutputDir);
|
ensureDir(scriptsOutputDir);
|
||||||
|
const sourcePath = path.join(scriptDir, `${helperName}.swift`);
|
||||||
|
const binaryPath = path.join(scriptsOutputDir, helperName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync(
|
execFileSync('swiftc', ['-O', '-target', macosHelperTarget(), sourcePath, '-o', binaryPath], {
|
||||||
'swiftc',
|
stdio: 'inherit',
|
||||||
['-O', '-target', macosHelperTarget(), macosHelperSourcePath, '-o', macosHelperBinaryPath],
|
});
|
||||||
{
|
fs.chmodSync(binaryPath, 0o755);
|
||||||
stdio: 'inherit',
|
process.stdout.write(`Built macOS helper: ${binaryPath}\n`);
|
||||||
},
|
|
||||||
);
|
|
||||||
fs.chmodSync(macosHelperBinaryPath, 0o755);
|
|
||||||
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
process.stdout.write('Failed to compile macOS helper; using source fallback.\n');
|
process.stdout.write(`Failed to compile macOS helper ${helperName}; using source fallback.\n`);
|
||||||
fallbackToMacosSource();
|
// A binary from an earlier build would otherwise pass the after-pack check and ship stale.
|
||||||
|
fs.rmSync(binaryPath, { force: true });
|
||||||
|
fallbackToMacosSource(helperName);
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
process.stderr.write(`${error.message}\n`);
|
process.stderr.write(`${error.message}\n`);
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ function main() {
|
|||||||
copyRendererAssets();
|
copyRendererAssets();
|
||||||
copySettingsAssets();
|
copySettingsAssets();
|
||||||
copySyncUiAssets();
|
copySyncUiAssets();
|
||||||
buildMacosHelper();
|
MACOS_HELPERS.forEach(buildMacosHelper);
|
||||||
stageIntegrationKeys();
|
stageIntegrationKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1687,6 +1687,176 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function count_live_timeouts(recorded, seconds)
|
||||||
|
local count = 0
|
||||||
|
for _, timeout in ipairs(recorded.timeout_handles) do
|
||||||
|
if timeout.seconds == seconds and not timeout.killed then
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fire only the short plugin timers (socket retries, deferred hides), leaving the 30 s loading deadline armed.
|
||||||
|
local function fire_short_timeouts(recorded)
|
||||||
|
for _, timeout in ipairs(recorded.timeout_handles) do
|
||||||
|
if not timeout.killed and timeout.callback and timeout.seconds < 5 then
|
||||||
|
timeout.killed = true
|
||||||
|
timeout.callback()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function count_live_periodic_timers(recorded)
|
||||||
|
local count = 0
|
||||||
|
for _, timer in ipairs(recorded.periodic_timers) do
|
||||||
|
if not timer.killed then
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
-- Issue #315: a warm playlist advance reuses an overlay whose content is already ready, so the
|
||||||
|
-- app never sends another overlay-loading-ready and a restarted spinner would hit its deadline.
|
||||||
|
local scenario = {
|
||||||
|
process_list = "",
|
||||||
|
defer_timeouts = true,
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "yes",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
path = "/media/warm-episode-01.mkv",
|
||||||
|
playlist_count = 2,
|
||||||
|
playlist_pos = 0,
|
||||||
|
media_title = "Warm Episode 1",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local recorded, err = run_plugin_scenario(scenario)
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for warm playlist loading OSD scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "start-file")
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
fire_short_timeouts(recorded)
|
||||||
|
recorded.script_messages["subminer-overlay-loading-ready"]()
|
||||||
|
recorded.script_messages["subminer-autoplay-ready"]()
|
||||||
|
fire_event(recorded, "end-file", { reason = "eof" })
|
||||||
|
scenario.path = "/media/warm-episode-02.mkv"
|
||||||
|
scenario.media_title = "Warm Episode 2"
|
||||||
|
scenario.playlist_pos = 1
|
||||||
|
recorded.osd = {}
|
||||||
|
fire_event(recorded, "start-file")
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
fire_short_timeouts(recorded)
|
||||||
|
assert_true(
|
||||||
|
not has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
|
||||||
|
"warm playlist advance should not restart the overlay loading OSD for an already-ready overlay"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
count_live_periodic_timers(recorded) == 0 and count_live_timeouts(recorded, 30) == 0,
|
||||||
|
"warm playlist advance should not leave an overlay loading spinner or deadline running"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
-- A stop-only dismissal (overlay hidden before content loaded) clears the spinner without marking
|
||||||
|
-- the overlay ready, so the next file still shows the spinner.
|
||||||
|
local scenario = {
|
||||||
|
process_list = "",
|
||||||
|
defer_timeouts = true,
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "no",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
path = "/media/slow-overlay-01.mkv",
|
||||||
|
playlist_count = 2,
|
||||||
|
playlist_pos = 0,
|
||||||
|
media_title = "Slow Overlay 1",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local recorded, err = run_plugin_scenario(scenario)
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for not-ready playlist loading OSD scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "start-file")
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
fire_short_timeouts(recorded)
|
||||||
|
assert_true(
|
||||||
|
count_live_periodic_timers(recorded) == 1 and count_live_timeouts(recorded, 30) == 1,
|
||||||
|
"overlay loading OSD should still be running before the app dismisses it"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
recorded.script_messages["subminer-overlay-loading-dismissed"] ~= nil,
|
||||||
|
"overlay loading dismissed script message should be registered"
|
||||||
|
)
|
||||||
|
recorded.script_messages["subminer-overlay-loading-dismissed"]()
|
||||||
|
assert_true(
|
||||||
|
count_live_periodic_timers(recorded) == 0 and count_live_timeouts(recorded, 30) == 0,
|
||||||
|
"overlay loading dismissed should stop the spinner and its deadline"
|
||||||
|
)
|
||||||
|
fire_event(recorded, "end-file", { reason = "eof" })
|
||||||
|
scenario.path = "/media/slow-overlay-02.mkv"
|
||||||
|
scenario.media_title = "Slow Overlay 2"
|
||||||
|
scenario.playlist_pos = 1
|
||||||
|
recorded.osd = {}
|
||||||
|
fire_event(recorded, "start-file")
|
||||||
|
assert_true(
|
||||||
|
has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
|
||||||
|
"a stop-only dismissal should not mark the overlay ready for the next playlist file"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
-- A dismissal after readiness means the app's overlay window was replaced and has no content yet,
|
||||||
|
-- so the plugin must forget the old readiness and show the spinner on the next file.
|
||||||
|
local scenario = {
|
||||||
|
process_list = "",
|
||||||
|
defer_timeouts = true,
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "no",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
path = "/media/replaced-overlay-01.mkv",
|
||||||
|
playlist_count = 2,
|
||||||
|
playlist_pos = 0,
|
||||||
|
media_title = "Replaced Overlay 1",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local recorded, err = run_plugin_scenario(scenario)
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for replaced overlay loading OSD scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "start-file")
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
fire_short_timeouts(recorded)
|
||||||
|
recorded.script_messages["subminer-overlay-loading-ready"]()
|
||||||
|
recorded.script_messages["subminer-overlay-loading-dismissed"]()
|
||||||
|
fire_event(recorded, "end-file", { reason = "eof" })
|
||||||
|
scenario.path = "/media/replaced-overlay-02.mkv"
|
||||||
|
scenario.media_title = "Replaced Overlay 2"
|
||||||
|
scenario.playlist_pos = 1
|
||||||
|
recorded.osd = {}
|
||||||
|
fire_event(recorded, "start-file")
|
||||||
|
assert_true(
|
||||||
|
has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
|
||||||
|
"a dismissal after readiness should make the next playlist file show the loading OSD again"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
defer_timeouts = true,
|
defer_timeouts = true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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';
|
||||||
import { AnkiIntegration } from './anki-integration';
|
import { AnkiIntegration } from './anki-integration';
|
||||||
|
import type { PluginMinedNote, PluginMiningProvenance } from './plugin-api';
|
||||||
import type { MediaInput } from './media-input';
|
import type { MediaInput } from './media-input';
|
||||||
import { AnkiConnectConfig } from './types';
|
import { AnkiConnectConfig } from './types';
|
||||||
|
|
||||||
@@ -82,6 +83,8 @@ type IntegrationInternals = {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}) => void;
|
}) => void;
|
||||||
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
|
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
|
||||||
|
reportCardsReady: (noteIds: number[]) => void;
|
||||||
|
rememberMiningProvenance: (noteIds: number[], provenance: PluginMiningProvenance | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Named stand-ins for AnkiIntegration's long positional constructor arguments. */
|
/** Named stand-ins for AnkiIntegration's long positional constructor arguments. */
|
||||||
@@ -273,6 +276,44 @@ test('AnkiIntegration resolves merged-away note ids to the kept note id', () =>
|
|||||||
assert.equal(integration.resolveCurrentNoteId(444), 444);
|
assert.equal(integration.resolveCurrentNoteId(444), 444);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration reports ready cards under the note that kept them after a merge', () => {
|
||||||
|
const integration = createIntegration({});
|
||||||
|
const ready: number[][] = [];
|
||||||
|
integration.setCardsReadyCallback((noteIds) => ready.push(noteIds));
|
||||||
|
const internals = internalsOf(integration);
|
||||||
|
internals.rememberMergedNoteIds(111, 222);
|
||||||
|
|
||||||
|
internals.reportCardsReady([111, 222, 444]);
|
||||||
|
|
||||||
|
assert.deepEqual(ready, [[222, 444]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ready events preserve each mining source when multiple notes merge into an older note', () => {
|
||||||
|
const integration = createIntegration({});
|
||||||
|
const ready: PluginMinedNote[][] = [];
|
||||||
|
integration.setCardsReadyCallback((_ids, notes) => ready.push(notes));
|
||||||
|
const internals = internalsOf(integration);
|
||||||
|
const provenance = {
|
||||||
|
minedAt: 1234,
|
||||||
|
media: { playbackId: 1, path: 'Show - 02.mkv', title: null },
|
||||||
|
subtitle: { text: 'テスト', startTime: 1, endTime: 2 },
|
||||||
|
};
|
||||||
|
internals.rememberMiningProvenance([111, 222], provenance);
|
||||||
|
provenance.media.path = 'Show - 03.mkv';
|
||||||
|
internals.rememberMergedNoteIds(111, 7);
|
||||||
|
internals.rememberMergedNoteIds(222, 7);
|
||||||
|
internals.reportCardsReady([111, 222]);
|
||||||
|
assert.deepEqual(
|
||||||
|
ready[0]?.map((note) => [note.originalNoteId, note.noteId, note.provenance?.media?.path]),
|
||||||
|
[
|
||||||
|
[111, 7, 'Show - 02.mkv'],
|
||||||
|
[222, 7, 'Show - 02.mkv'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
internals.reportCardsReady([333]);
|
||||||
|
assert.equal(ready[1]?.[0]?.provenance, null);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Sentence highlighting --------------------------------------------------
|
// --- Sentence highlighting --------------------------------------------------
|
||||||
|
|
||||||
const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―';
|
const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―';
|
||||||
@@ -404,6 +445,57 @@ test('AnkiIntegration triggers field grouping after a local duplicate sentence c
|
|||||||
assert.equal(groupingTriggered, 1);
|
assert.equal(groupingTriggered, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration reports a new sentence card ready only after field grouping', async () => {
|
||||||
|
const integration = createIntegration({
|
||||||
|
isKiku: { enabled: true, fieldGrouping: 'manual' },
|
||||||
|
});
|
||||||
|
const events: string[] = [];
|
||||||
|
integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`));
|
||||||
|
const internals = internalsOf(integration);
|
||||||
|
internals.cardCreationService = {
|
||||||
|
createSentenceCard: async () => {
|
||||||
|
integration.trackDuplicateNoteIdsForNote(42, [7]);
|
||||||
|
internals.reportCardsReady([42]);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
internals.fieldGroupingService = {
|
||||||
|
triggerFieldGroupingForLastAddedCard: async () => {
|
||||||
|
events.push('group');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true);
|
||||||
|
assert.deepEqual(events, ['group', 'ready 42']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration reports a card with queued YouTube media ready once the media lands', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const integration = createIntegration({
|
||||||
|
fields: { audio: 'ExpressionAudio', image: 'Picture' },
|
||||||
|
media: { imageFormat: 'jpg' },
|
||||||
|
behavior: { notificationType: 'osd' },
|
||||||
|
});
|
||||||
|
installFakeAnki(integration, {
|
||||||
|
noteFieldNames: ['ExpressionAudio', 'Picture'],
|
||||||
|
mediaGenerator: {
|
||||||
|
generateAudio: async () => Buffer.from('audio'),
|
||||||
|
generateScreenshot: async () => Buffer.from('image'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`));
|
||||||
|
const internals = internalsOf(integration);
|
||||||
|
internals.queuePendingYoutubeMediaUpdate(youtubeJob({ noteId: 101 }));
|
||||||
|
// Note 7 was merged into 101, so it waits on 101's media too.
|
||||||
|
internals.rememberMergedNoteIds(7, 101);
|
||||||
|
|
||||||
|
internals.reportCardsReady([101, 7, 5]);
|
||||||
|
assert.deepEqual(events, ['ready 5']);
|
||||||
|
|
||||||
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||||
|
assert.deepEqual(events, ['ready 5', 'ready 101']);
|
||||||
|
});
|
||||||
|
|
||||||
test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => {
|
test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => {
|
||||||
const osdMessages: string[] = [];
|
const osdMessages: string[] = [];
|
||||||
const integration = createIntegration(
|
const integration = createIntegration(
|
||||||
@@ -637,7 +729,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
noteId: 404,
|
noteId: 404,
|
||||||
noteInfo: {
|
noteInfo: {
|
||||||
noteId: 404,
|
noteId: 404,
|
||||||
fields: { ExpressionAudio: { value: '' }, Picture: { value: '' } },
|
fields: { SentenceAudio: { value: '' }, Picture: { value: '' } },
|
||||||
},
|
},
|
||||||
label: 'resolved source',
|
label: 'resolved source',
|
||||||
});
|
});
|
||||||
@@ -648,8 +740,8 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
assert.equal(queued, true);
|
assert.equal(queued, true);
|
||||||
assert.equal(updatedNotes.length, 1);
|
assert.equal(updatedNotes.length, 1);
|
||||||
assert.equal(updatedNotes[0]?.noteId, 404);
|
assert.equal(updatedNotes[0]?.noteId, 404);
|
||||||
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio_/);
|
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||||
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
|
assert.equal(updatedNotes[0]?.fields.ExpressionAudio, undefined);
|
||||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||||
assert.equal(storedMedia.length, 2);
|
assert.equal(storedMedia.length, 2);
|
||||||
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
||||||
|
|||||||
+128
-17
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { AnkiConnectClient } from './anki-connect';
|
import { AnkiConnectClient } from './anki-connect';
|
||||||
|
import type { PluginMinedNote, PluginMiningProvenance } from './plugin-api';
|
||||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||||
import { MediaGenerator } from './media-generator';
|
import { MediaGenerator } from './media-generator';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -241,6 +242,11 @@ export class AnkiIntegration {
|
|||||||
private runtime: AnkiIntegrationRuntime;
|
private runtime: AnkiIntegrationRuntime;
|
||||||
private aiConfig: AiConfig;
|
private aiConfig: AiConfig;
|
||||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||||
|
private cardsReadyCallback: ((noteIds: number[], notes: PluginMinedNote[]) => void) | null = null;
|
||||||
|
private miningProvenanceProvider: (() => PluginMiningProvenance | null) | null = null;
|
||||||
|
private miningProvenance = new Map<number, PluginMiningProvenance | null>();
|
||||||
|
// Ready reports collected while createSentenceCard still has field grouping to run.
|
||||||
|
private heldReadyNoteIds: number[] | null = null;
|
||||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||||
private generateSentenceFuriganaCallback:
|
private generateSentenceFuriganaCallback:
|
||||||
@@ -337,6 +343,74 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private reportCardsReady(noteIds: number[]): void {
|
||||||
|
if (this.heldReadyNoteIds) {
|
||||||
|
this.heldReadyNoteIds.push(...noteIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Notes still waiting on queued YouTube media, directly or through the note they were
|
||||||
|
// merged into, are reported by the queue once it settles.
|
||||||
|
const queue = this.pendingYoutubeMediaQueue;
|
||||||
|
this.emitCardsReady(
|
||||||
|
noteIds.filter(
|
||||||
|
(noteId) =>
|
||||||
|
!queue.hasPendingNote(noteId) && !queue.hasPendingNote(this.resolveCurrentNoteId(noteId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitCardsReady(noteIds: number[]): void {
|
||||||
|
if (noteIds.length === 0) return;
|
||||||
|
// Field grouping may have merged a note away; report the note that kept its content.
|
||||||
|
const currentNoteIds = [...new Set(noteIds.map((noteId) => this.resolveCurrentNoteId(noteId)))];
|
||||||
|
const notes: PluginMinedNote[] = [];
|
||||||
|
for (const [originalNoteId, provenance] of this.miningProvenance) {
|
||||||
|
const noteId = this.resolveCurrentNoteId(originalNoteId);
|
||||||
|
if (
|
||||||
|
!currentNoteIds.includes(noteId) ||
|
||||||
|
this.pendingYoutubeMediaQueue.hasPendingNote(originalNoteId) ||
|
||||||
|
this.pendingYoutubeMediaQueue.hasPendingNote(noteId)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
notes.push({ originalNoteId, noteId, provenance });
|
||||||
|
this.miningProvenance.delete(originalNoteId);
|
||||||
|
}
|
||||||
|
for (const originalNoteId of noteIds) {
|
||||||
|
const noteId = this.resolveCurrentNoteId(originalNoteId);
|
||||||
|
if (
|
||||||
|
!notes.some((note) => note.noteId === noteId) &&
|
||||||
|
![...this.miningProvenance.keys()].some((id) => this.resolveCurrentNoteId(id) === noteId)
|
||||||
|
) {
|
||||||
|
notes.push({ originalNoteId, noteId, provenance: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notes.length === 0) return;
|
||||||
|
try {
|
||||||
|
this.cardsReadyCallback?.([...new Set(notes.map((note) => note.noteId))], notes);
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('cardsReady callback failed:', (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberMiningProvenance(
|
||||||
|
noteIds: number[],
|
||||||
|
provenance: PluginMiningProvenance | null,
|
||||||
|
): void {
|
||||||
|
for (const noteId of noteIds) {
|
||||||
|
if (!this.miningProvenance.get(noteId)) {
|
||||||
|
this.miningProvenance.set(
|
||||||
|
noteId,
|
||||||
|
provenance &&
|
||||||
|
Object.freeze({
|
||||||
|
...provenance,
|
||||||
|
media: provenance.media && Object.freeze({ ...provenance.media }),
|
||||||
|
subtitle: provenance.subtitle && Object.freeze({ ...provenance.subtitle }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||||
const options: MediaGenerationInputResolverOptions = {
|
const options: MediaGenerationInputResolverOptions = {
|
||||||
logDebug: (message) => log.debug(message),
|
logDebug: (message) => log.debug(message),
|
||||||
@@ -415,6 +489,7 @@ export class AnkiIntegration {
|
|||||||
showStatusNotification: (message) => this.showStatusNotification(message),
|
showStatusNotification: (message) => this.showStatusNotification(message),
|
||||||
showNotification: (noteId, label, errorSuffix) =>
|
showNotification: (noteId, label, errorSuffix) =>
|
||||||
this.showNotification(noteId, label, errorSuffix),
|
this.showNotification(noteId, label, errorSuffix),
|
||||||
|
onUpdateSettled: (noteId) => this.reportCardsReady([noteId]),
|
||||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||||
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
||||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||||
@@ -445,6 +520,10 @@ export class AnkiIntegration {
|
|||||||
recordCardsAdded: (count, noteIds) => {
|
recordCardsAdded: (count, noteIds) => {
|
||||||
this.recordCardsMinedSafely(count, noteIds, 'polling');
|
this.recordCardsMinedSafely(count, noteIds, 'polling');
|
||||||
},
|
},
|
||||||
|
onCardsReady: (noteIds) => {
|
||||||
|
this.rememberMiningProvenance(noteIds, null);
|
||||||
|
this.reportCardsReady(noteIds);
|
||||||
|
},
|
||||||
isUpdateInProgress: () => this.updateInProgress,
|
isUpdateInProgress: () => this.updateInProgress,
|
||||||
setUpdateInProgress: (value) => {
|
setUpdateInProgress: (value) => {
|
||||||
this.updateInProgress = value;
|
this.updateInProgress = value;
|
||||||
@@ -464,11 +543,14 @@ export class AnkiIntegration {
|
|||||||
const { AnkiConnectProxyServer } =
|
const { AnkiConnectProxyServer } =
|
||||||
require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy');
|
require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy');
|
||||||
return new AnkiConnectProxyServer({
|
return new AnkiConnectProxyServer({
|
||||||
|
captureMiningProvenance: () => this.miningProvenanceProvider?.() ?? null,
|
||||||
|
onNotesAdded: (noteIds, provenance) => this.rememberMiningProvenance(noteIds, provenance),
|
||||||
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
||||||
processNewCard: (noteId: number) => this.processNewCard(noteId),
|
processNewCard: (noteId: number) => this.processNewCard(noteId),
|
||||||
recordCardsAdded: (count, noteIds) => {
|
recordCardsAdded: (count, noteIds) => {
|
||||||
this.recordCardsMinedSafely(count, noteIds, 'proxy');
|
this.recordCardsMinedSafely(count, noteIds, 'proxy');
|
||||||
},
|
},
|
||||||
|
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
|
||||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
|
this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
|
||||||
},
|
},
|
||||||
@@ -594,6 +676,9 @@ export class AnkiIntegration {
|
|||||||
recordCardsMinedCallback: (count, noteIds) => {
|
recordCardsMinedCallback: (count, noteIds) => {
|
||||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||||
},
|
},
|
||||||
|
captureMiningProvenance: () => this.miningProvenanceProvider?.() ?? null,
|
||||||
|
onNoteCreated: (noteId, provenance) => this.rememberMiningProvenance([noteId], provenance),
|
||||||
|
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
|
||||||
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1544,25 +1629,36 @@ export class AnkiIntegration {
|
|||||||
secondarySubText?: string,
|
secondarySubText?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys());
|
const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys());
|
||||||
const created = await this.cardCreationService.createSentenceCard(
|
// Field grouping below can still rewrite the new card, so hold its ready report until then.
|
||||||
sentence,
|
const ownsHold = this.heldReadyNoteIds === null;
|
||||||
startTime,
|
if (ownsHold) this.heldReadyNoteIds = [];
|
||||||
endTime,
|
try {
|
||||||
secondarySubText,
|
const created = await this.cardCreationService.createSentenceCard(
|
||||||
);
|
sentence,
|
||||||
if (
|
startTime,
|
||||||
created &&
|
endTime,
|
||||||
this.shouldTriggerFieldGroupingAfterLocalSentenceCardCreate(
|
secondarySubText,
|
||||||
trackedDuplicateNoteIdsBeforeCreate,
|
);
|
||||||
)
|
if (
|
||||||
) {
|
created &&
|
||||||
try {
|
this.shouldTriggerFieldGroupingAfterLocalSentenceCardCreate(
|
||||||
await this.fieldGroupingService.triggerFieldGroupingForLastAddedCard();
|
trackedDuplicateNoteIdsBeforeCreate,
|
||||||
} catch (error) {
|
)
|
||||||
log.warn('Failed to trigger field grouping after sentence card creation:', error);
|
) {
|
||||||
|
try {
|
||||||
|
await this.fieldGroupingService.triggerFieldGroupingForLastAddedCard();
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('Failed to trigger field grouping after sentence card creation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
} finally {
|
||||||
|
if (ownsHold) {
|
||||||
|
const held = this.heldReadyNoteIds ?? [];
|
||||||
|
this.heldReadyNoteIds = null;
|
||||||
|
this.reportCardsReady(held);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return created;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void {
|
trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void {
|
||||||
@@ -1781,6 +1877,21 @@ export class AnkiIntegration {
|
|||||||
this.recordCardsMinedCallback = callback;
|
this.recordCardsMinedCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once SubMiner has finished writing a mined card (fields, media including queued YouTube
|
||||||
|
* media, and field grouping), or as soon as polling or the proxy sees it when auto-update is off.
|
||||||
|
* Ids merged away by field grouping are reported as the note that kept them.
|
||||||
|
*/
|
||||||
|
setCardsReadyCallback(
|
||||||
|
callback: ((noteIds: number[], notes: PluginMinedNote[]) => void) | null,
|
||||||
|
): void {
|
||||||
|
this.cardsReadyCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMiningProvenanceProvider(provider: (() => PluginMiningProvenance | null) | null): void {
|
||||||
|
this.miningProvenanceProvider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void {
|
setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void {
|
||||||
this.knownWordCacheUpdatedCallback = callback;
|
this.knownWordCacheUpdatedCallback = callback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
|||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { AnkiConnectProxyServer } from './anki-connect-proxy';
|
import { AnkiConnectProxyServer } from './anki-connect-proxy';
|
||||||
|
import type { PluginMiningProvenance } from '../plugin-api';
|
||||||
|
|
||||||
async function waitForCondition(
|
async function waitForCondition(
|
||||||
condition: () => boolean,
|
condition: () => boolean,
|
||||||
@@ -17,17 +18,23 @@ async function waitForCondition(
|
|||||||
throw new Error('Timed out waiting for condition');
|
throw new Error('Timed out waiting for condition');
|
||||||
}
|
}
|
||||||
|
|
||||||
test('proxy enqueues addNote result for enrichment', async () => {
|
test('proxy enqueues addNote result for enrichment and reports it ready afterwards', async () => {
|
||||||
const processed: number[] = [];
|
const processed: number[] = [];
|
||||||
const recordedCards: number[] = [];
|
const recordedCards: number[] = [];
|
||||||
|
const events: string[] = [];
|
||||||
const proxy = new AnkiConnectProxyServer({
|
const proxy = new AnkiConnectProxyServer({
|
||||||
shouldAutoUpdateNewCards: () => true,
|
shouldAutoUpdateNewCards: () => true,
|
||||||
processNewCard: async (noteId) => {
|
processNewCard: async (noteId) => {
|
||||||
processed.push(noteId);
|
processed.push(noteId);
|
||||||
|
events.push(`process ${noteId}`);
|
||||||
|
throw new Error('enrichment failed');
|
||||||
},
|
},
|
||||||
recordCardsAdded: (count) => {
|
recordCardsAdded: (count) => {
|
||||||
recordedCards.push(count);
|
recordedCards.push(count);
|
||||||
},
|
},
|
||||||
|
onCardsReady: (noteIds) => {
|
||||||
|
events.push(`ready ${noteIds.join(',')}`);
|
||||||
|
},
|
||||||
logInfo: () => undefined,
|
logInfo: () => undefined,
|
||||||
logWarn: () => undefined,
|
logWarn: () => undefined,
|
||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
@@ -42,9 +49,44 @@ test('proxy enqueues addNote result for enrichment', async () => {
|
|||||||
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitForCondition(() => processed.length === 1);
|
await waitForCondition(() => events.length === 2);
|
||||||
assert.deepEqual(processed, [42]);
|
assert.deepEqual(processed, [42]);
|
||||||
assert.deepEqual(recordedCards, [1]);
|
assert.deepEqual(recordedCards, [1]);
|
||||||
|
assert.deepEqual(events, ['process 42', 'ready 42']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy reports an added note ready right away when auto-update is off', () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const recordedCards: number[] = [];
|
||||||
|
const ready: number[][] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => false,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
recordCardsAdded: (count) => {
|
||||||
|
recordedCards.push(count);
|
||||||
|
},
|
||||||
|
onCardsReady: (noteIds) => {
|
||||||
|
ready.push(noteIds);
|
||||||
|
},
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
proxy as unknown as {
|
||||||
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
|
}
|
||||||
|
).maybeEnqueueFromRequest(
|
||||||
|
{ action: 'addNote' },
|
||||||
|
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(ready, [[42]]);
|
||||||
|
assert.deepEqual(processed, []);
|
||||||
|
assert.deepEqual(recordedCards, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('proxy enqueues addNote bare numeric response for enrichment', async () => {
|
test('proxy enqueues addNote bare numeric response for enrichment', async () => {
|
||||||
@@ -360,8 +402,11 @@ test('proxy tracks duplicate note ids from addNote request metadata before enric
|
|||||||
assert.deepEqual(processed, [42]);
|
assert.deepEqual(processed, [42]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('proxy strips SubMiner duplicate metadata before forwarding upstream addNote request', async () => {
|
test('proxy strips duplicate metadata and captures mining provenance before the upstream request', async () => {
|
||||||
let upstreamBody = '';
|
let upstreamBody = '';
|
||||||
|
let mediaPath = 'Show - 02.mkv';
|
||||||
|
let captureCount = 0;
|
||||||
|
const sources: Array<PluginMiningProvenance | null> = [];
|
||||||
const upstream = http.createServer(async (req, res) => {
|
const upstream = http.createServer(async (req, res) => {
|
||||||
upstreamBody = await new Promise<string>((resolve) => {
|
upstreamBody = await new Promise<string>((resolve) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -369,6 +414,7 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
|||||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
});
|
});
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
|
mediaPath = 'Show - 03.mkv';
|
||||||
res.setHeader('content-type', 'application/json');
|
res.setHeader('content-type', 'application/json');
|
||||||
res.end(JSON.stringify({ result: 42, error: null }));
|
res.end(JSON.stringify({ result: 42, error: null }));
|
||||||
});
|
});
|
||||||
@@ -380,6 +426,15 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
|||||||
|
|
||||||
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||||
const proxy = new AnkiConnectProxyServer({
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
captureMiningProvenance: () => {
|
||||||
|
captureCount++;
|
||||||
|
return {
|
||||||
|
minedAt: 1234,
|
||||||
|
media: { playbackId: 1, path: mediaPath, title: null },
|
||||||
|
subtitle: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onNotesAdded: (_ids, provenance) => sources.push(provenance),
|
||||||
shouldAutoUpdateNewCards: () => true,
|
shouldAutoUpdateNewCards: () => true,
|
||||||
processNewCard: async () => undefined,
|
processNewCard: async () => undefined,
|
||||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
@@ -434,6 +489,16 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
|||||||
await waitForCondition(() => tracked.length === 1);
|
await waitForCondition(() => tracked.length === 1);
|
||||||
assert.equal(upstreamBody.includes('subminerDuplicateNoteIds'), false);
|
assert.equal(upstreamBody.includes('subminerDuplicateNoteIds'), false);
|
||||||
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [7, 18] }]);
|
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [7, 18] }]);
|
||||||
|
assert.equal(sources[0]?.media?.path, 'Show - 02.mkv');
|
||||||
|
assert.equal(captureCount, 1);
|
||||||
|
const preflight = await fetch(`http://127.0.0.1:${proxyPort}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'canAddNotes', version: 6, params: { notes: [] } }),
|
||||||
|
});
|
||||||
|
assert.equal(preflight.status, 200);
|
||||||
|
await preflight.json();
|
||||||
|
assert.equal(captureCount, 1);
|
||||||
} finally {
|
} finally {
|
||||||
proxy.stop();
|
proxy.stop();
|
||||||
upstream.close();
|
upstream.close();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import http, { IncomingMessage, ServerResponse } from 'node:http';
|
import http, { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import type { PluginMiningProvenance } from '../plugin-api';
|
||||||
|
|
||||||
interface StartProxyOptions {
|
interface StartProxyOptions {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -13,9 +14,13 @@ interface AnkiConnectEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnkiConnectProxyServerDeps {
|
export interface AnkiConnectProxyServerDeps {
|
||||||
|
captureMiningProvenance?: () => PluginMiningProvenance | null;
|
||||||
|
onNotesAdded?: (noteIds: number[], provenance: PluginMiningProvenance | null) => void;
|
||||||
shouldAutoUpdateNewCards: () => boolean;
|
shouldAutoUpdateNewCards: () => boolean;
|
||||||
processNewCard: (noteId: number) => Promise<void>;
|
processNewCard: (noteId: number) => Promise<void>;
|
||||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||||
|
/** Called once SubMiner is done with an added note: after enrichment, or right away when it is off. */
|
||||||
|
onCardsReady?: (noteIds: number[]) => void;
|
||||||
trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
getDeck?: () => string | undefined;
|
getDeck?: () => string | undefined;
|
||||||
findNotes?: (
|
findNotes?: (
|
||||||
@@ -186,6 +191,12 @@ export class AnkiConnectProxyServer {
|
|||||||
typeof req.headers['content-type'] === 'string'
|
typeof req.headers['content-type'] === 'string'
|
||||||
? req.headers['content-type']
|
? req.headers['content-type']
|
||||||
: 'application/json';
|
: 'application/json';
|
||||||
|
const action = requestJson?.action;
|
||||||
|
const provenance =
|
||||||
|
typeof action === 'string' &&
|
||||||
|
['addNote', 'addNotes', 'multi', 'updateNoteFields'].includes(action)
|
||||||
|
? (this.deps.captureMiningProvenance?.() ?? null)
|
||||||
|
: null;
|
||||||
const upstreamResponse = await this.client.request<ArrayBuffer>({
|
const upstreamResponse = await this.client.request<ArrayBuffer>({
|
||||||
url: targetUrl,
|
url: targetUrl,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
@@ -203,7 +214,7 @@ export class AnkiConnectProxyServer {
|
|||||||
res.end(responseBody);
|
res.end(responseBody);
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
this.maybeEnqueueFromRequest(requestJson, responseBody);
|
this.maybeEnqueueFromRequest(requestJson, responseBody, provenance);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.deps.logWarn('[anki-proxy] Failed to forward request:', (error as Error).message);
|
this.deps.logWarn('[anki-proxy] Failed to forward request:', (error as Error).message);
|
||||||
@@ -215,16 +226,19 @@ export class AnkiConnectProxyServer {
|
|||||||
private maybeEnqueueFromRequest(
|
private maybeEnqueueFromRequest(
|
||||||
requestJson: Record<string, unknown> | null,
|
requestJson: Record<string, unknown> | null,
|
||||||
responseBody: Buffer,
|
responseBody: Buffer,
|
||||||
|
provenance: PluginMiningProvenance | null = null,
|
||||||
): void {
|
): void {
|
||||||
if (!requestJson || !this.deps.shouldAutoUpdateNewCards()) {
|
if (!requestJson) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const autoUpdate = this.deps.shouldAutoUpdateNewCards();
|
||||||
|
|
||||||
const action =
|
const action =
|
||||||
typeof requestJson.action === 'string'
|
typeof requestJson.action === 'string'
|
||||||
? requestJson.action
|
? requestJson.action
|
||||||
: String(requestJson.action ?? '');
|
: String(requestJson.action ?? '');
|
||||||
if (action === 'updateNoteFields') {
|
if (action === 'updateNoteFields') {
|
||||||
|
if (!autoUpdate) return;
|
||||||
const params = requestJson.params;
|
const params = requestJson.params;
|
||||||
if (
|
if (
|
||||||
!params ||
|
!params ||
|
||||||
@@ -246,6 +260,7 @@ export class AnkiConnectProxyServer {
|
|||||||
response.result !== null
|
response.result !== null
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
this.deps.onNotesAdded?.(this.collectSingleResultId(note.id), provenance);
|
||||||
this.enqueueNotes(this.collectSingleResultId(note.id), false);
|
this.enqueueNotes(this.collectSingleResultId(note.id), false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -277,9 +292,16 @@ export class AnkiConnectProxyServer {
|
|||||||
params.subminerEnrich === false
|
params.subminerEnrich === false
|
||||||
) {
|
) {
|
||||||
// Stats owns the saved sentence and media; the live mpv context is unrelated.
|
// Stats owns the saved sentence and media; the live mpv context is unrelated.
|
||||||
if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
|
if (autoUpdate && noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!autoUpdate) {
|
||||||
|
// Nothing to enrich, so the added notes are already in their final state.
|
||||||
|
this.deps.onNotesAdded?.(noteIds, provenance);
|
||||||
|
if (noteIds.length > 0) this.deps.onCardsReady?.(noteIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deps.onNotesAdded?.(noteIds, provenance);
|
||||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||||
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
|
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
|
||||||
void this.enqueueMostRecentAddedNote();
|
void this.enqueueMostRecentAddedNote();
|
||||||
@@ -534,6 +556,7 @@ export class AnkiConnectProxyServer {
|
|||||||
this.pendingNoteIdSet.delete(noteId);
|
this.pendingNoteIdSet.delete(noteId);
|
||||||
|
|
||||||
if (!this.deps.shouldAutoUpdateNewCards()) {
|
if (!this.deps.shouldAutoUpdateNewCards()) {
|
||||||
|
this.deps.onCardsReady?.([noteId]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +570,7 @@ export class AnkiConnectProxyServer {
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.inFlightNoteIds.delete(noteId);
|
this.inFlightNoteIds.delete(noteId);
|
||||||
|
this.deps.onCardsReady?.([noteId]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -11,12 +11,37 @@ import {
|
|||||||
} from './card-creation-test-utils';
|
} from './card-creation-test-utils';
|
||||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
|
import type { PluginMiningProvenance } from '../plugin-api';
|
||||||
|
|
||||||
const YOUTUBE_URL = 'https://www.youtube.com/watch?v=abc123';
|
const YOUTUBE_URL = 'https://www.youtube.com/watch?v=abc123';
|
||||||
|
|
||||||
const mediaInputPath = (mediaInput: MediaInput): string =>
|
const mediaInputPath = (mediaInput: MediaInput): string =>
|
||||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||||
|
|
||||||
|
test('sentence provenance is captured before asynchronous creation and uses the mined line', async () => {
|
||||||
|
let currentPath = 'Show - 02.mkv';
|
||||||
|
const captured: Array<PluginMiningProvenance | null> = [];
|
||||||
|
const service = new CardCreationService(
|
||||||
|
createCardCreationDeps({
|
||||||
|
captureMiningProvenance: () => ({
|
||||||
|
minedAt: 1234,
|
||||||
|
media: { playbackId: 1, path: currentPath, title: null },
|
||||||
|
subtitle: null,
|
||||||
|
}),
|
||||||
|
client: createFakeClient({
|
||||||
|
addNote: async () => {
|
||||||
|
currentPath = 'Show - 03.mkv';
|
||||||
|
return 42;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onNoteCreated: (_noteId, provenance) => captured.push(provenance),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(await service.createSentenceCard('テスト', 1, 2), true);
|
||||||
|
assert.equal(captured[0]?.media?.path, 'Show - 02.mkv');
|
||||||
|
assert.deepEqual(captured[0]?.subtitle, { text: 'テスト', startTime: 1, endTime: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
test('CardCreationService counts locally created sentence cards', async () => {
|
test('CardCreationService counts locally created sentence cards', async () => {
|
||||||
const minedCards: Array<{ count: number; noteIds?: number[] }> = [];
|
const minedCards: Array<{ count: number; noteIds?: number[] }> = [];
|
||||||
const service = new CardCreationService(
|
const service = new CardCreationService(
|
||||||
@@ -31,6 +56,58 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
|||||||
assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]);
|
assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const delay of [5, -5]) {
|
||||||
|
test(`sentence provenance stays on the subtitle clock with delay ${delay}`, async () => {
|
||||||
|
let captured: PluginMiningProvenance | null = null;
|
||||||
|
const audioRanges: number[][] = [];
|
||||||
|
const defaults = createCardCreationDeps();
|
||||||
|
const service = new CardCreationService(
|
||||||
|
createCardCreationDeps({
|
||||||
|
getConfig: () => createAnkiConfig({ media: { generateAudio: true } }),
|
||||||
|
getMpvClient: () => ({ ...defaults.getMpvClient()!, currentSubDelay: delay }),
|
||||||
|
captureMiningProvenance: () => ({ minedAt: 1234, media: null, subtitle: null }),
|
||||||
|
onNoteCreated: (_id, provenance) => {
|
||||||
|
captured = provenance;
|
||||||
|
},
|
||||||
|
mediaGenerator: createFakeMediaGenerator({
|
||||||
|
generateAudio: async (_path, start, end) => {
|
||||||
|
audioRanges.push([start, end]);
|
||||||
|
return Buffer.from('audio');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(await service.createSentenceCard('first\nsecond', 10 + delay, 12 + delay), true);
|
||||||
|
assert.partialDeepStrictEqual(captured, {
|
||||||
|
subtitle: { text: 'first\nsecond', startTime: 10, endTime: 12 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(audioRanges, [[10 + delay, 12 + delay]]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('CardCreationService reports a created card ready after its fields are written', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const service = new CardCreationService(
|
||||||
|
createCardCreationDeps({
|
||||||
|
client: createFakeClient({
|
||||||
|
notesInfo: async () => [{ noteId: 42, fields: { Sentence: { value: 'existing' } } }],
|
||||||
|
updateNoteFields: async () => {
|
||||||
|
events.push('update');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setCardTypeFields: (updatedFields) => {
|
||||||
|
updatedFields.CardType = 'sentence';
|
||||||
|
},
|
||||||
|
onCardsReady: (noteIds) => {
|
||||||
|
events.push(`ready ${noteIds.join(',')}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
|
||||||
|
assert.deepEqual(events, ['update', 'ready 42']);
|
||||||
|
});
|
||||||
|
|
||||||
const THROWING_HOOKS: Array<{
|
const THROWING_HOOKS: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
hook: Partial<CardCreationDeps>;
|
hook: Partial<CardCreationDeps>;
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
type MediaTimingReviewRequest,
|
type MediaTimingReviewRequest,
|
||||||
type WordCardKind,
|
type WordCardKind,
|
||||||
} from '../types/anki';
|
} from '../types/anki';
|
||||||
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
|
import { shiftSubtitleRangeToVideoClock, toSubtitleClock } from '../core/services/subtitle-clock';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
|
import type { PluginMiningProvenance } from '../plugin-api';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
import { AiConfig } from '../types/integrations';
|
import { AiConfig } from '../types/integrations';
|
||||||
@@ -102,6 +103,8 @@ interface CardCreationMediaGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CardCreationDeps {
|
interface CardCreationDeps {
|
||||||
|
captureMiningProvenance?: () => PluginMiningProvenance | null;
|
||||||
|
onNoteCreated?: (noteId: number, provenance: PluginMiningProvenance | null) => void;
|
||||||
getConfig: () => AnkiConnectConfig;
|
getConfig: () => AnkiConnectConfig;
|
||||||
getAiConfig: () => AiConfig;
|
getAiConfig: () => AiConfig;
|
||||||
getTimingTracker: () => SubtitleTimingTracker;
|
getTimingTracker: () => SubtitleTimingTracker;
|
||||||
@@ -153,6 +156,8 @@ interface CardCreationDeps {
|
|||||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||||
|
/** Called once card creation is done writing a card; queued YouTube media may still be pending. */
|
||||||
|
onCardsReady?: (noteIds: number[]) => void;
|
||||||
reviewMediaTiming?: (
|
reviewMediaTiming?: (
|
||||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||||
) => Promise<MediaTimingReviewDecision>;
|
) => Promise<MediaTimingReviewDecision>;
|
||||||
@@ -603,6 +608,15 @@ export class CardCreationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
|
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
|
||||||
|
const captured = this.deps.captureMiningProvenance?.() ?? null;
|
||||||
|
const provenance = captured && {
|
||||||
|
...captured,
|
||||||
|
subtitle: {
|
||||||
|
text: sentence,
|
||||||
|
startTime: toSubtitleClock(startTime, mpvClient.currentSubDelay ?? 0),
|
||||||
|
endTime: toSubtitleClock(endTime, mpvClient.currentSubDelay ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
|
const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
|
||||||
if (cappedEndTime !== endTime) {
|
if (cappedEndTime !== endTime) {
|
||||||
log.warn(
|
log.warn(
|
||||||
@@ -611,6 +625,7 @@ export class CardCreationService {
|
|||||||
endTime = cappedEndTime;
|
endTime = cappedEndTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let createdNoteId: number | null = null;
|
||||||
try {
|
try {
|
||||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||||
const timingDecision = this.deps.reviewMediaTiming
|
const timingDecision = this.deps.reviewMediaTiming
|
||||||
@@ -723,6 +738,8 @@ export class CardCreationService {
|
|||||||
fields,
|
fields,
|
||||||
this.getConfiguredAnkiTags(),
|
this.getConfiguredAnkiTags(),
|
||||||
);
|
);
|
||||||
|
createdNoteId = noteId;
|
||||||
|
this.deps.onNoteCreated?.(noteId, provenance);
|
||||||
log.info('Created sentence card:', noteId);
|
log.info('Created sentence card:', noteId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Failed to create sentence card:', (error as Error).message);
|
log.error('Failed to create sentence card:', (error as Error).message);
|
||||||
@@ -887,6 +904,8 @@ export class CardCreationService {
|
|||||||
log.error('Error creating sentence card:', (error as Error).message);
|
log.error('Error creating sentence card:', (error as Error).message);
|
||||||
this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
|
this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (createdNoteId !== null) this.deps.onCardsReady?.([createdNoteId]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface PendingYoutubeMediaQueueDeps {
|
|||||||
) => string;
|
) => string;
|
||||||
showStatusNotification: (message: string) => void;
|
showStatusNotification: (message: string) => void;
|
||||||
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
|
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
|
||||||
|
/** Called once a queued note's media was applied or dropped, so the card is in its final state. */
|
||||||
|
onUpdateSettled?: (noteId: number) => void;
|
||||||
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;
|
||||||
@@ -79,9 +81,18 @@ function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
|||||||
|
|
||||||
export class PendingYoutubeMediaQueue {
|
export class PendingYoutubeMediaQueue {
|
||||||
private updates: PendingYoutubeMediaUpdate[] = [];
|
private updates: PendingYoutubeMediaUpdate[] = [];
|
||||||
|
// Jobs taken off the queue whose media is still being applied or dropped.
|
||||||
|
private settlingNoteIds = new Set<number>();
|
||||||
|
|
||||||
constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {}
|
constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {}
|
||||||
|
|
||||||
|
/** True while the note still waits on queued media. */
|
||||||
|
hasPendingNote(noteId: number): boolean {
|
||||||
|
return (
|
||||||
|
this.settlingNoteIds.has(noteId) || this.updates.some((update) => update.noteId === noteId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
enqueue(job: PendingYoutubeMediaUpdate): void {
|
enqueue(job: PendingYoutubeMediaUpdate): void {
|
||||||
if (!job.generateAudio && !job.generateImage) {
|
if (!job.generateAudio && !job.generateImage) {
|
||||||
return;
|
return;
|
||||||
@@ -195,6 +206,8 @@ export class PendingYoutubeMediaQueue {
|
|||||||
'Failed to apply queued YouTube media update:',
|
'Failed to apply queued YouTube media update:',
|
||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
this.settle(job);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,16 +247,24 @@ export class PendingYoutubeMediaQueue {
|
|||||||
'Failed to show queued YouTube media failure notification:',
|
'Failed to show queued YouTube media failure notification:',
|
||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
this.settle(job);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private settle(job: PendingYoutubeMediaUpdate): void {
|
||||||
|
this.settlingNoteIds.delete(job.noteId);
|
||||||
|
this.deps.onUpdateSettled?.(job.noteId);
|
||||||
|
}
|
||||||
|
|
||||||
private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] {
|
private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] {
|
||||||
const matched: PendingYoutubeMediaUpdate[] = [];
|
const matched: PendingYoutubeMediaUpdate[] = [];
|
||||||
const remaining: PendingYoutubeMediaUpdate[] = [];
|
const remaining: PendingYoutubeMediaUpdate[] = [];
|
||||||
for (const job of this.updates) {
|
for (const job of this.updates) {
|
||||||
if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) {
|
if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) {
|
||||||
matched.push(job);
|
matched.push(job);
|
||||||
|
this.settlingNoteIds.add(job.noteId);
|
||||||
} else {
|
} else {
|
||||||
remaining.push(job);
|
remaining.push(job);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import { PollingRunner } from './polling';
|
import { PollingRunner } from './polling';
|
||||||
|
|
||||||
test('polling runner records newly added cards after initialization', async () => {
|
test('polling runner records new cards and reports each ready after enrichment', async () => {
|
||||||
const originalDateNow = Date.now;
|
const originalDateNow = Date.now;
|
||||||
const recordedCards: number[] = [];
|
const recordedCards: number[] = [];
|
||||||
|
const events: string[] = [];
|
||||||
let tracked = new Set<number>();
|
let tracked = new Set<number>();
|
||||||
const responses = [
|
const responses = [
|
||||||
[10, 11],
|
[10, 11],
|
||||||
@@ -16,12 +17,17 @@ test('polling runner records newly added cards after initialization', async () =
|
|||||||
const runner = new PollingRunner({
|
const runner = new PollingRunner({
|
||||||
getDeck: () => 'Mining',
|
getDeck: () => 'Mining',
|
||||||
getPollingRate: () => 250,
|
getPollingRate: () => 250,
|
||||||
findNotes: async () => responses.shift() ?? [],
|
findNotes: async (query) => (query.includes('tag:') ? [] : (responses.shift() ?? [])),
|
||||||
shouldAutoUpdateNewCards: () => true,
|
shouldAutoUpdateNewCards: () => true,
|
||||||
processNewCard: async () => undefined,
|
processNewCard: async (noteId) => {
|
||||||
|
events.push(`process ${noteId}`);
|
||||||
|
},
|
||||||
recordCardsAdded: (count) => {
|
recordCardsAdded: (count) => {
|
||||||
recordedCards.push(count);
|
recordedCards.push(count);
|
||||||
},
|
},
|
||||||
|
onCardsReady: (noteIds) => {
|
||||||
|
events.push(`ready ${noteIds.join(',')}`);
|
||||||
|
},
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
getTrackedNoteIds: () => tracked,
|
getTrackedNoteIds: () => tracked,
|
||||||
@@ -38,7 +44,132 @@ test('polling runner records newly added cards after initialization', async () =
|
|||||||
await runner.pollOnce();
|
await runner.pollOnce();
|
||||||
|
|
||||||
assert.deepEqual(recordedCards, [2]);
|
assert.deepEqual(recordedCards, [2]);
|
||||||
|
assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']);
|
||||||
} finally {
|
} finally {
|
||||||
Date.now = originalDateNow;
|
Date.now = originalDateNow;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('polling runner keeps going after one note fails to enrich', async () => {
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
const events: string[] = [];
|
||||||
|
const warnings: unknown[][] = [];
|
||||||
|
let tracked = new Set<number>();
|
||||||
|
const responses = [[10], [10, 12, 13]];
|
||||||
|
try {
|
||||||
|
Date.now = () => 120_000;
|
||||||
|
const runner = new PollingRunner({
|
||||||
|
getDeck: () => undefined,
|
||||||
|
getPollingRate: () => 250,
|
||||||
|
findNotes: async (query) => (query.includes('tag:') ? [] : (responses.shift() ?? [])),
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
events.push(`process ${noteId}`);
|
||||||
|
if (noteId === 12) throw new Error('overlay callback failed');
|
||||||
|
},
|
||||||
|
onCardsReady: (noteIds) => {
|
||||||
|
events.push(`ready ${noteIds.join(',')}`);
|
||||||
|
},
|
||||||
|
isUpdateInProgress: () => false,
|
||||||
|
setUpdateInProgress: () => undefined,
|
||||||
|
getTrackedNoteIds: () => tracked,
|
||||||
|
setTrackedNoteIds: (noteIds) => {
|
||||||
|
tracked = noteIds;
|
||||||
|
},
|
||||||
|
showStatusNotification: () => undefined,
|
||||||
|
logDebug: () => undefined,
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: (...args) => {
|
||||||
|
warnings.push(args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runner.pollOnce();
|
||||||
|
await runner.pollOnce();
|
||||||
|
|
||||||
|
assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']);
|
||||||
|
assert.deepEqual(warnings, [['Failed to auto-enrich note 12:', 'overlay callback failed']]);
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createStatsHarness(autoUpdate: boolean, sourceLookupFails = false) {
|
||||||
|
let currentIds: number[] = [];
|
||||||
|
let tracked = new Set<number>();
|
||||||
|
const recorded: Array<{ count: number; noteIds: number[] }> = [];
|
||||||
|
const processed: number[] = [];
|
||||||
|
const ready: number[][] = [];
|
||||||
|
const warnings: unknown[][] = [];
|
||||||
|
const queries: string[] = [];
|
||||||
|
const runner = new PollingRunner({
|
||||||
|
getDeck: () => 'Mining',
|
||||||
|
getPollingRate: () => 250,
|
||||||
|
findNotes: async (query) => {
|
||||||
|
queries.push(query);
|
||||||
|
if (!query.includes('tag:')) return currentIds;
|
||||||
|
if (sourceLookupFails) throw new Error('source lookup failed');
|
||||||
|
return [2];
|
||||||
|
},
|
||||||
|
shouldAutoUpdateNewCards: () => autoUpdate,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
recordCardsAdded: (count, noteIds) => {
|
||||||
|
recorded.push({ count, noteIds });
|
||||||
|
},
|
||||||
|
onCardsReady: (noteIds) => {
|
||||||
|
ready.push(noteIds);
|
||||||
|
},
|
||||||
|
isUpdateInProgress: () => false,
|
||||||
|
setUpdateInProgress: () => {},
|
||||||
|
getTrackedNoteIds: () => tracked,
|
||||||
|
setTrackedNoteIds: (noteIds) => {
|
||||||
|
tracked = noteIds;
|
||||||
|
},
|
||||||
|
showStatusNotification: () => assert.fail('source lookup failures must not back off polling'),
|
||||||
|
logDebug: () => {},
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: (...args) => {
|
||||||
|
warnings.push(args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
recorded,
|
||||||
|
processed,
|
||||||
|
ready,
|
||||||
|
warnings,
|
||||||
|
queries,
|
||||||
|
poll: async (noteIds: number[]) => {
|
||||||
|
currentIds = noteIds;
|
||||||
|
await runner.pollOnce();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const autoUpdate of [true, false]) {
|
||||||
|
test(`stats cards count without triggering plugins when auto-update is ${autoUpdate}`, async () => {
|
||||||
|
const h = createStatsHarness(autoUpdate);
|
||||||
|
await h.poll([]);
|
||||||
|
await h.poll([1, 2]);
|
||||||
|
assert.deepEqual(h.recorded, [{ count: 2, noteIds: [1, 2] }]);
|
||||||
|
assert.deepEqual(h.processed, autoUpdate ? [1, 2] : []);
|
||||||
|
assert.deepEqual(h.ready, [[1]]);
|
||||||
|
assert.ok(h.queries.includes('nid:1,2 tag:"SubMiner::Stats"'));
|
||||||
|
assert.deepEqual(h.warnings, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a failed source lookup skips plugin events without stopping counting or enrichment', async () => {
|
||||||
|
const h = createStatsHarness(true, true);
|
||||||
|
await h.poll([]);
|
||||||
|
await h.poll([1, 2]);
|
||||||
|
await h.poll([1, 2, 3]);
|
||||||
|
assert.deepEqual(h.processed, [1, 2, 3]);
|
||||||
|
assert.deepEqual(h.recorded, [
|
||||||
|
{ count: 2, noteIds: [1, 2] },
|
||||||
|
{ count: 1, noteIds: [3] },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(h.ready, []);
|
||||||
|
assert.equal(h.warnings.length, 2);
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||||
|
|
||||||
export interface PollingRunnerDeps {
|
export interface PollingRunnerDeps {
|
||||||
getDeck: () => string | undefined;
|
getDeck: () => string | undefined;
|
||||||
getPollingRate: () => number;
|
getPollingRate: () => number;
|
||||||
@@ -10,6 +12,8 @@ export interface PollingRunnerDeps {
|
|||||||
shouldAutoUpdateNewCards: () => boolean;
|
shouldAutoUpdateNewCards: () => boolean;
|
||||||
processNewCard: (noteId: number) => Promise<void>;
|
processNewCard: (noteId: number) => Promise<void>;
|
||||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||||
|
/** Called once SubMiner is done with a new note: after enrichment, or at detection when it is off. */
|
||||||
|
onCardsReady?: (noteIds: number[]) => void;
|
||||||
isUpdateInProgress: () => boolean;
|
isUpdateInProgress: () => boolean;
|
||||||
setUpdateInProgress: (value: boolean) => void;
|
setUpdateInProgress: (value: boolean) => void;
|
||||||
getTrackedNoteIds: () => Set<number>;
|
getTrackedNoteIds: () => Set<number>;
|
||||||
@@ -83,11 +87,39 @@ export class PollingRunner {
|
|||||||
this.deps.setTrackedNoteIds(previousNoteIds);
|
this.deps.setTrackedNoteIds(previousNoteIds);
|
||||||
this.deps.recordCardsAdded?.(newNoteIds.length, newNoteIds);
|
this.deps.recordCardsAdded?.(newNoteIds.length, newNoteIds);
|
||||||
|
|
||||||
|
// Stats cards still count and update known words, but do not trigger plugins.
|
||||||
|
const pluginNoteIds = new Set<number>();
|
||||||
|
if (this.deps.onCardsReady) {
|
||||||
|
try {
|
||||||
|
const statsNoteIds = new Set(
|
||||||
|
await this.deps.findNotes(`nid:${newNoteIds.join(',')} tag:"${STATS_MINING_TAG}"`, {
|
||||||
|
maxRetries: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const noteId of newNoteIds) {
|
||||||
|
if (!statsNoteIds.has(noteId)) pluginNoteIds.add(noteId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.deps.logWarn(
|
||||||
|
'Skipping plugin events because note sources could not be checked:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.deps.shouldAutoUpdateNewCards()) {
|
if (this.deps.shouldAutoUpdateNewCards()) {
|
||||||
for (const noteId of newNoteIds) {
|
for (const noteId of newNoteIds) {
|
||||||
await this.deps.processNewCard(noteId);
|
try {
|
||||||
|
await this.deps.processNewCard(noteId);
|
||||||
|
} catch (error) {
|
||||||
|
// One failed note must not leave the rest of the batch unprocessed.
|
||||||
|
this.deps.logWarn(`Failed to auto-enrich note ${noteId}:`, (error as Error).message);
|
||||||
|
} finally {
|
||||||
|
if (pluginNoteIds.has(noteId)) this.deps.onCardsReady?.([noteId]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (pluginNoteIds.size > 0) this.deps.onCardsReady?.([...pluginNoteIds]);
|
||||||
this.deps.logInfo(
|
this.deps.logInfo(
|
||||||
'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.',
|
'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -340,6 +340,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(shouldStartApp(sessionAction), true);
|
assert.equal(shouldStartApp(sessionAction), true);
|
||||||
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
|
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
|
||||||
|
|
||||||
|
const pluginAction = parseArgs(['--plugin-action', 'anime/open']);
|
||||||
|
assert.equal(pluginAction.pluginAction, 'anime/open');
|
||||||
|
assert.equal(parseArgs(['--plugin-action=anime/open']).pluginAction, 'anime/open');
|
||||||
|
assert.equal(hasExplicitCommand(pluginAction), true);
|
||||||
|
assert.equal(shouldStartApp(pluginAction), true);
|
||||||
|
// The plugin decides what it needs; a window-only action must not force the overlay up.
|
||||||
|
assert.equal(commandNeedsOverlayRuntime(pluginAction), false);
|
||||||
|
|
||||||
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface CliArgs {
|
|||||||
cycleRuntimeOptionId?: string;
|
cycleRuntimeOptionId?: string;
|
||||||
cycleRuntimeOptionDirection?: 1 | -1;
|
cycleRuntimeOptionDirection?: 1 | -1;
|
||||||
sessionAction?: SessionActionDispatchRequest;
|
sessionAction?: SessionActionDispatchRequest;
|
||||||
|
/** `<plugin id>/<action name>` to run, like a `__plugin-action:` keybinding. */
|
||||||
|
pluginAction?: string;
|
||||||
copySubtitleCount?: number;
|
copySubtitleCount?: number;
|
||||||
mineSentenceCount?: number;
|
mineSentenceCount?: number;
|
||||||
anilistStatus: boolean;
|
anilistStatus: boolean;
|
||||||
@@ -338,6 +340,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--session-action') {
|
} else if (arg === '--session-action') {
|
||||||
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
||||||
if (parsed) args.sessionAction = parsed;
|
if (parsed) args.sessionAction = parsed;
|
||||||
|
} else if (arg.startsWith('--plugin-action=')) {
|
||||||
|
const value = arg.slice('--plugin-action='.length).trim();
|
||||||
|
if (value) args.pluginAction = value;
|
||||||
|
} else if (arg === '--plugin-action') {
|
||||||
|
const value = readValue(argv[i + 1])?.trim();
|
||||||
|
if (value) args.pluginAction = value;
|
||||||
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
const value = Number(arg.split('=', 2)[1]);
|
||||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
||||||
@@ -601,6 +609,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
|
args.pluginAction !== undefined ||
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.anilistStatus ||
|
args.anilistStatus ||
|
||||||
@@ -682,6 +691,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
|
args.pluginAction === undefined &&
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
@@ -752,6 +762,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
|
args.pluginAction !== undefined ||
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.dictionary ||
|
args.dictionary ||
|
||||||
@@ -819,6 +830,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
|
args.pluginAction === undefined &&
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ ${B}Mining${R}
|
|||||||
--open-session-help Open session help modal
|
--open-session-help Open session help modal
|
||||||
--open-controller-select Open controller select modal
|
--open-controller-select Open controller select modal
|
||||||
--open-controller-debug Open controller debug modal
|
--open-controller-debug Open controller debug modal
|
||||||
|
--plugin-action ${D}<id>/<name>${R} Run a plugin action
|
||||||
|
|
||||||
${B}AniList${R}
|
${B}AniList${R}
|
||||||
--anilist-setup Open AniList authentication flow
|
--anilist-setup Open AniList authentication flow
|
||||||
|
|||||||
+195
-1
@@ -1297,7 +1297,6 @@ test('parses controller settings with logical bindings and tuning knobs', () =>
|
|||||||
"toggleMpvPause": "leftStickPress",
|
"toggleMpvPause": "leftStickPress",
|
||||||
"leftStickHorizontal": "rightStickX",
|
"leftStickHorizontal": "rightStickX",
|
||||||
"leftStickVertical": "rightStickY",
|
"leftStickVertical": "rightStickY",
|
||||||
"rightStickHorizontal": "leftStickX",
|
|
||||||
"rightStickVertical": "leftStickY"
|
"rightStickVertical": "leftStickY"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1383,6 +1382,59 @@ test('parses descriptor-based controller bindings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses a hold input on discrete controller bindings and rejects an invalid one', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"controller": {
|
||||||
|
"bindings": {
|
||||||
|
"nextAudio": {
|
||||||
|
"kind": "button",
|
||||||
|
"buttonIndex": 15,
|
||||||
|
"hold": { "kind": "button", "buttonIndex": 4 }
|
||||||
|
},
|
||||||
|
"nextEntry": {
|
||||||
|
"kind": "axis",
|
||||||
|
"axisIndex": 0,
|
||||||
|
"direction": "positive",
|
||||||
|
"hold": { "kind": "axis", "axisIndex": 2, "direction": "positive" }
|
||||||
|
},
|
||||||
|
"mineCard": {
|
||||||
|
"kind": "button",
|
||||||
|
"buttonIndex": 12,
|
||||||
|
"hold": { "kind": "button" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
|
||||||
|
assert.deepEqual(config.controller.bindings.nextAudio, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 15,
|
||||||
|
hold: { kind: 'button', buttonIndex: 4 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.controller.bindings.nextEntry, {
|
||||||
|
kind: 'axis',
|
||||||
|
axisIndex: 0,
|
||||||
|
direction: 'positive',
|
||||||
|
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||||
|
});
|
||||||
|
// A broken hold must not silently become a plain press of button 12.
|
||||||
|
assert.deepEqual(
|
||||||
|
config.controller.bindings.mineCard,
|
||||||
|
DEFAULT_CONFIG.controller.bindings.mineCard,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
service.getWarnings().some((warning) => warning.path === 'controller.bindings.mineCard'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('parses controller profiles as per-gamepad binding overrides', () => {
|
test('parses controller profiles as per-gamepad binding overrides', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -2190,3 +2242,145 @@ test('template generator shows built-in default keybindings in the keybindings a
|
|||||||
|
|
||||||
assert.deepEqual(parsed.keybindings, DEFAULT_KEYBINDINGS);
|
assert.deepEqual(parsed.keybindings, DEFAULT_KEYBINDINGS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses controller face button labels and warns on unknown styles', () => {
|
||||||
|
const validDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(validDir, 'config.jsonc'),
|
||||||
|
'{ "controller": { "faceButtonLabels": "nintendo" } }',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const validService = new ConfigService(validDir);
|
||||||
|
assert.equal(DEFAULT_CONFIG.controller.faceButtonLabels, 'xbox');
|
||||||
|
assert.equal(validService.getConfig().controller.faceButtonLabels, 'nintendo');
|
||||||
|
assert.deepEqual(validService.getWarnings(), []);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
'{ "controller": { "faceButtonLabels": "sega" } }',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(invalidService.getConfig().controller.faceButtonLabels, 'xbox');
|
||||||
|
assert.ok(
|
||||||
|
invalidService.getWarnings().some((warning) => warning.path === 'controller.faceButtonLabels'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller profiles inherit the button style unless they set their own', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
JSON.stringify({
|
||||||
|
controller: {
|
||||||
|
faceButtonLabels: 'nintendo',
|
||||||
|
profiles: {
|
||||||
|
'pad-ps': { faceButtonLabels: 'playstation' },
|
||||||
|
'pad-plain': { label: 'Plain' },
|
||||||
|
'pad-bad': { faceButtonLabels: 'sega' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const { profiles } = service.getConfig().controller;
|
||||||
|
|
||||||
|
assert.equal(profiles['pad-ps']!.faceButtonLabels, 'playstation');
|
||||||
|
assert.equal(profiles['pad-plain']!.faceButtonLabels, 'nintendo');
|
||||||
|
assert.equal(profiles['pad-bad']!.faceButtonLabels, 'nintendo');
|
||||||
|
assert.deepEqual(
|
||||||
|
service.getWarnings().map((warning) => warning.path),
|
||||||
|
['controller.profiles.pad-bad.faceButtonLabels'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses controller review bindings globally and per profile, and warns on bad entries', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
JSON.stringify({
|
||||||
|
controller: {
|
||||||
|
reviewBindings: {
|
||||||
|
confirm: { press: { kind: 'button', buttonIndex: 9 } },
|
||||||
|
discard: { kind: 'none' },
|
||||||
|
play: { press: { kind: 'face', letter: 'q' } },
|
||||||
|
notAnAction: { kind: 'none' },
|
||||||
|
},
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
reviewBindings: {
|
||||||
|
startForward: {
|
||||||
|
press: { kind: 'dpad', direction: 'right' },
|
||||||
|
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const controller = service.getConfig().controller;
|
||||||
|
|
||||||
|
assert.deepEqual(controller.reviewBindings.confirm, {
|
||||||
|
kind: 'combo',
|
||||||
|
press: { kind: 'button', buttonIndex: 9 },
|
||||||
|
hold: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(controller.reviewBindings.discard, { kind: 'none' });
|
||||||
|
// Invalid entries keep the default.
|
||||||
|
assert.deepEqual(controller.reviewBindings.play, DEFAULT_CONFIG.controller.reviewBindings.play);
|
||||||
|
// Profiles start from the resolved global layout, then apply their own overrides.
|
||||||
|
const profile = controller.profiles['pad-1']!.reviewBindings;
|
||||||
|
assert.deepEqual(profile.confirm, controller.reviewBindings.confirm);
|
||||||
|
assert.deepEqual(profile.startForward, {
|
||||||
|
kind: 'combo',
|
||||||
|
press: { kind: 'dpad', direction: 'right' },
|
||||||
|
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||||
|
});
|
||||||
|
const warnedPaths = service.getWarnings().map((warning) => warning.path);
|
||||||
|
assert.ok(warnedPaths.includes('controller.reviewBindings.play'));
|
||||||
|
assert.ok(warnedPaths.includes('controller.reviewBindings.notAnAction'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses duplicate card field grouping bindings globally and per profile', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
JSON.stringify({
|
||||||
|
controller: {
|
||||||
|
fieldGroupingBindings: {
|
||||||
|
cancelMerge: { press: { kind: 'button', buttonIndex: 8 } },
|
||||||
|
// A review action is not a field grouping action.
|
||||||
|
confirm: { kind: 'none' },
|
||||||
|
},
|
||||||
|
profiles: {
|
||||||
|
'pad-1': { fieldGroupingBindings: { back: { kind: 'none' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const controller = service.getConfig().controller;
|
||||||
|
|
||||||
|
assert.deepEqual(controller.fieldGroupingBindings.cancelMerge, {
|
||||||
|
kind: 'combo',
|
||||||
|
press: { kind: 'button', buttonIndex: 8 },
|
||||||
|
hold: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
controller.fieldGroupingBindings.continue,
|
||||||
|
DEFAULT_CONFIG.controller.fieldGroupingBindings.continue,
|
||||||
|
);
|
||||||
|
const profile = controller.profiles['pad-1']!.fieldGroupingBindings;
|
||||||
|
assert.deepEqual(profile.cancelMerge, controller.fieldGroupingBindings.cancelMerge);
|
||||||
|
assert.deepEqual(profile.back, { kind: 'none' });
|
||||||
|
// The review layout is untouched.
|
||||||
|
assert.deepEqual(controller.reviewBindings, DEFAULT_CONFIG.controller.reviewBindings);
|
||||||
|
const warnedPaths = service.getWarnings().map((warning) => warning.path);
|
||||||
|
assert.ok(warnedPaths.includes('controller.fieldGroupingBindings.confirm'));
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ const {
|
|||||||
startupWarmups,
|
startupWarmups,
|
||||||
updates,
|
updates,
|
||||||
notifications,
|
notifications,
|
||||||
|
plugins,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
} = CORE_DEFAULT_CONFIG;
|
} = CORE_DEFAULT_CONFIG;
|
||||||
const {
|
const {
|
||||||
@@ -51,7 +52,7 @@ const {
|
|||||||
discordPresence,
|
discordPresence,
|
||||||
ai,
|
ai,
|
||||||
} = INTEGRATIONS_DEFAULT_CONFIG;
|
} = INTEGRATIONS_DEFAULT_CONFIG;
|
||||||
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
const { subtitleStyle, subtitleSidebar, subtitleFilter } = SUBTITLE_DEFAULT_CONFIG;
|
||||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||||
const { stats } = STATS_DEFAULT_CONFIG;
|
const { stats } = STATS_DEFAULT_CONFIG;
|
||||||
|
|
||||||
@@ -74,8 +75,10 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
startupWarmups,
|
startupWarmups,
|
||||||
updates,
|
updates,
|
||||||
notifications,
|
notifications,
|
||||||
|
plugins,
|
||||||
subtitleStyle,
|
subtitleStyle,
|
||||||
subtitleSidebar,
|
subtitleSidebar,
|
||||||
|
subtitleFilter,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
jimaku,
|
jimaku,
|
||||||
tsukihime,
|
tsukihime,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
|
||||||
|
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
|
||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
|
|
||||||
export const CORE_DEFAULT_CONFIG: Pick<
|
export const CORE_DEFAULT_CONFIG: Pick<
|
||||||
@@ -17,6 +19,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
| 'startupWarmups'
|
| 'startupWarmups'
|
||||||
| 'updates'
|
| 'updates'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
|
| 'plugins'
|
||||||
| 'auto_start_overlay'
|
| 'auto_start_overlay'
|
||||||
> = {
|
> = {
|
||||||
dictionaryBackend: 'yomitan',
|
dictionaryBackend: 'yomitan',
|
||||||
@@ -53,6 +56,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
stickDeadzone: 0.2,
|
stickDeadzone: 0.2,
|
||||||
triggerInputMode: 'auto',
|
triggerInputMode: 'auto',
|
||||||
triggerDeadzone: 0.5,
|
triggerDeadzone: 0.5,
|
||||||
|
faceButtonLabels: 'xbox',
|
||||||
repeatDelayMs: 320,
|
repeatDelayMs: 320,
|
||||||
repeatIntervalMs: 120,
|
repeatIntervalMs: 120,
|
||||||
buttonIndices: {
|
buttonIndices: {
|
||||||
@@ -78,11 +82,20 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||||
|
holdSecondarySub: { kind: 'none' },
|
||||||
|
previousEntry: { kind: 'none' },
|
||||||
|
nextEntry: { kind: 'none' },
|
||||||
|
toggleCharacterNavigation: { kind: 'none' },
|
||||||
|
previousSubtitle: { kind: 'none' },
|
||||||
|
nextSubtitle: { kind: 'none' },
|
||||||
|
replaySubtitle: { kind: 'none' },
|
||||||
|
playNextSubtitle: { kind: 'none' },
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
reviewBindings: getDefaultControllerReviewBindings(),
|
||||||
|
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||||
profiles: {},
|
profiles: {},
|
||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
@@ -145,5 +158,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
notifications: {
|
notifications: {
|
||||||
overlayPosition: 'top-right',
|
overlayPosition: 'top-right',
|
||||||
},
|
},
|
||||||
|
plugins: {},
|
||||||
auto_start_overlay: true,
|
auto_start_overlay: true,
|
||||||
};
|
};
|
||||||
@@ -28,7 +28,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
deck: '',
|
deck: '',
|
||||||
fields: {
|
fields: {
|
||||||
word: 'Expression',
|
word: 'Expression',
|
||||||
audio: 'ExpressionAudio',
|
audio: 'SentenceAudio',
|
||||||
wordAudio: 'ExpressionAudio',
|
wordAudio: 'ExpressionAudio',
|
||||||
image: 'Picture',
|
image: 'Picture',
|
||||||
sentence: 'Sentence',
|
sentence: 'Sentence',
|
||||||
@@ -108,6 +108,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
apiKeyCommand: '',
|
apiKeyCommand: '',
|
||||||
languagePreference: 'ja',
|
languagePreference: 'ja',
|
||||||
maxEntryResults: 10,
|
maxEntryResults: 10,
|
||||||
|
autoOpenWhenPrimarySubtitleMissing: false,
|
||||||
},
|
},
|
||||||
tsukihime: {
|
tsukihime: {
|
||||||
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
|
|
||||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
export const SUBTITLE_DEFAULT_CONFIG: Pick<
|
||||||
|
ResolvedConfig,
|
||||||
|
'subtitleStyle' | 'subtitleSidebar' | 'subtitleFilter'
|
||||||
|
> = {
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
primaryDefaultMode: 'visible',
|
primaryDefaultMode: 'visible',
|
||||||
css: {},
|
css: {},
|
||||||
@@ -8,6 +11,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
preserveLineBreaks: false,
|
preserveLineBreaks: false,
|
||||||
autoPauseVideoOnHover: true,
|
autoPauseVideoOnHover: true,
|
||||||
autoPauseVideoOnYomitanPopup: true,
|
autoPauseVideoOnYomitanPopup: true,
|
||||||
|
autoPauseVideoOnKeyboardMode: false,
|
||||||
primaryVisibleOnYomitanPopup: true,
|
primaryVisibleOnYomitanPopup: true,
|
||||||
hoverTokenColor: '#f4dbd6',
|
hoverTokenColor: '#f4dbd6',
|
||||||
hoverTokenBackgroundColor: 'transparent',
|
hoverTokenBackgroundColor: 'transparent',
|
||||||
@@ -93,4 +97,13 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
},
|
},
|
||||||
|
subtitleFilter: {
|
||||||
|
presets: {
|
||||||
|
parentheses: false,
|
||||||
|
brackets: false,
|
||||||
|
speakerPrefix: false,
|
||||||
|
musicSymbols: false,
|
||||||
|
},
|
||||||
|
customRules: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { CONTROLLER_SCREENS, type ControllerScreenId } from '../../shared/controller-screens';
|
||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
|
import type { ControllerReviewInput, ResolvedControllerReviewBinding } from '../../types/runtime';
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_TYPE_VALUES,
|
NOTIFICATION_TYPE_VALUES,
|
||||||
OVERLAY_NOTIFICATION_POSITION_VALUES,
|
OVERLAY_NOTIFICATION_POSITION_VALUES,
|
||||||
@@ -6,6 +8,12 @@ import {
|
|||||||
} from '../../types/notification';
|
} from '../../types/notification';
|
||||||
import { ConfigOptionRegistryEntry } from './shared';
|
import { ConfigOptionRegistryEntry } from './shared';
|
||||||
|
|
||||||
|
const CONTROLLER_SCREEN_INTROS: Record<ControllerScreenId, string> = {
|
||||||
|
review: 'Controller layout for the media timing review dialog, one entry per action.',
|
||||||
|
fieldGrouping:
|
||||||
|
'Controller layout for the duplicate card (field grouping) dialog, one entry per action.',
|
||||||
|
};
|
||||||
|
|
||||||
export function buildCoreConfigOptionRegistry(
|
export function buildCoreConfigOptionRegistry(
|
||||||
defaultConfig: ResolvedConfig,
|
defaultConfig: ResolvedConfig,
|
||||||
): ConfigOptionRegistryEntry[] {
|
): ConfigOptionRegistryEntry[] {
|
||||||
@@ -53,7 +61,56 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
{
|
{
|
||||||
id: 'toggleMpvPause',
|
id: 'toggleMpvPause',
|
||||||
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
|
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
|
||||||
description: 'Controller binding descriptor for toggling mpv play/pause.',
|
description:
|
||||||
|
'Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'holdSecondarySub',
|
||||||
|
defaultValue: defaultConfig.controller.bindings.holdSecondarySub,
|
||||||
|
description:
|
||||||
|
'Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'previousEntry',
|
||||||
|
defaultValue: defaultConfig.controller.bindings.previousEntry,
|
||||||
|
description:
|
||||||
|
'Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nextEntry',
|
||||||
|
defaultValue: defaultConfig.controller.bindings.nextEntry,
|
||||||
|
description:
|
||||||
|
'Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggleCharacterNavigation',
|
||||||
|
defaultValue: defaultConfig.controller.bindings.toggleCharacterNavigation,
|
||||||
|
description:
|
||||||
|
'Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'previousSubtitle',
|
||||||
|
defaultValue: defaultConfig.controller.bindings.previousSubtitle,
|
||||||
|
description:
|
||||||
|
'Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nextSubtitle',
|
||||||
|
defaultValue: defaultConfig.controller.bindings.nextSubtitle,
|
||||||
|
description:
|
||||||
|
'Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'replaySubtitle',
|
||||||
|
defaultValue: defaultConfig.controller.bindings.replaySubtitle,
|
||||||
|
description:
|
||||||
|
'Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'playNextSubtitle',
|
||||||
|
defaultValue: defaultConfig.controller.bindings.playNextSubtitle,
|
||||||
|
description:
|
||||||
|
'Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode.',
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -68,11 +125,6 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
|
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
|
||||||
description: 'Axis binding descriptor used for primary popup scrolling.',
|
description: 'Axis binding descriptor used for primary popup scrolling.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'rightStickHorizontal',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
|
|
||||||
description: 'Axis binding descriptor reserved for alternate right-stick mappings.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'rightStickVertical',
|
id: 'rightStickVertical',
|
||||||
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
|
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
|
||||||
@@ -206,6 +258,14 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
|
'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.faceButtonLabels',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['xbox', 'nintendo', 'playstation'],
|
||||||
|
defaultValue: defaultConfig.controller.faceButtonLabels,
|
||||||
|
description:
|
||||||
|
'Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'controller.triggerDeadzone',
|
path: 'controller.triggerDeadzone',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
@@ -312,12 +372,102 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.',
|
'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.',
|
||||||
},
|
},
|
||||||
|
...CONTROLLER_SCREENS.flatMap((screen) => {
|
||||||
|
const defaults = defaultConfig.controller[screen.configKey] as Record<
|
||||||
|
string,
|
||||||
|
ResolvedControllerReviewBinding
|
||||||
|
>;
|
||||||
|
const stepLabels = new Map(screen.steps.map((step, index) => [step.id, index]));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path: `controller.${screen.configKey}`,
|
||||||
|
kind: 'object' as const,
|
||||||
|
defaultValue: defaults,
|
||||||
|
description: `${CONTROLLER_SCREEN_INTROS[screen.id]} Set it with the ${screen.title} group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.`,
|
||||||
|
},
|
||||||
|
...screen.actions.flatMap((action): ConfigOptionRegistryEntry[] => {
|
||||||
|
const path = `controller.${screen.configKey}.${action.id}`;
|
||||||
|
const binding = defaults[action.id]!;
|
||||||
|
const press = binding.kind === 'combo' ? binding.press : undefined;
|
||||||
|
const hold = binding.kind === 'combo' ? binding.hold : undefined;
|
||||||
|
// Steps after the first are named, e.g. "(after Cancel)".
|
||||||
|
const stepNote =
|
||||||
|
(stepLabels.get(action.step) ?? 0) > 0
|
||||||
|
? ` (${screen.steps.find((step) => step.id === action.step)!.label.toLowerCase()})`
|
||||||
|
: '';
|
||||||
|
const inputFields = (
|
||||||
|
name: 'press' | 'hold',
|
||||||
|
input: ControllerReviewInput | null | undefined,
|
||||||
|
): ConfigOptionRegistryEntry[] => [
|
||||||
|
{
|
||||||
|
path: `${path}.${name}.kind`,
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['button', 'axis', 'face', 'dpad'],
|
||||||
|
defaultValue: input?.kind,
|
||||||
|
description:
|
||||||
|
'Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${path}.${name}.buttonIndex`,
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: input?.kind === 'button' ? input.buttonIndex : undefined,
|
||||||
|
description: 'Raw button index.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${path}.${name}.letter`,
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['a', 'b', 'x', 'y'],
|
||||||
|
defaultValue: input?.kind === 'face' ? input.letter : undefined,
|
||||||
|
description: 'Printed face letter, placed by controller.faceButtonLabels.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${path}.${name}.direction`,
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['left', 'right', 'up', 'down', 'negative', 'positive'],
|
||||||
|
defaultValue:
|
||||||
|
input?.kind === 'dpad' || input?.kind === 'axis' ? input.direction : undefined,
|
||||||
|
description: 'D-pad direction, or axis direction for raw axis inputs.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path,
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: binding,
|
||||||
|
description: `${screen.describe}: ${action.label}${stepNote}.${action.repeat ? ' Repeats while held.' : ''}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${path}.kind`,
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['none', 'combo'],
|
||||||
|
defaultValue: binding.kind,
|
||||||
|
description:
|
||||||
|
'none disables the action; combo presses "press" (while holding "hold", if set).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${path}.press`,
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: press,
|
||||||
|
description: 'Input that triggers the action.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${path}.hold`,
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: hold,
|
||||||
|
description: 'Optional input held while pressing; null for none.',
|
||||||
|
},
|
||||||
|
...inputFields('press', press),
|
||||||
|
...inputFields('hold', hold),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}),
|
||||||
...discreteBindings.flatMap((binding) => [
|
...discreteBindings.flatMap((binding) => [
|
||||||
{
|
{
|
||||||
path: `controller.bindings.${binding.id}`,
|
path: `controller.bindings.${binding.id}`,
|
||||||
kind: 'object' as const,
|
kind: 'object' as const,
|
||||||
defaultValue: binding.defaultValue,
|
defaultValue: binding.defaultValue,
|
||||||
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.`,
|
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `controller.bindings.${binding.id}.kind`,
|
path: `controller.bindings.${binding.id}.kind`,
|
||||||
@@ -350,6 +500,13 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
|
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: `controller.bindings.${binding.id}.hold`,
|
||||||
|
kind: 'object' as const,
|
||||||
|
defaultValue: undefined,
|
||||||
|
description:
|
||||||
|
'Optional button or axis input, in the same form, that must be held down while pressing this one.',
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
...axisBindings.flatMap((binding) => [
|
...axisBindings.flatMap((binding) => [
|
||||||
{
|
{
|
||||||
@@ -546,6 +703,13 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.notifications.overlayPosition,
|
defaultValue: defaultConfig.notifications.overlayPosition,
|
||||||
description: 'Position for in-overlay notification cards.',
|
description: 'Position for in-overlay notification cards.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'plugins',
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: defaultConfig.plugins,
|
||||||
|
description:
|
||||||
|
'Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'shortcuts.multiCopyTimeoutMs',
|
path: 'shortcuts.multiCopyTimeoutMs',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
|
|||||||
@@ -467,6 +467,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.jimaku.maxEntryResults,
|
defaultValue: defaultConfig.jimaku.maxEntryResults,
|
||||||
description: 'Maximum Jimaku search results returned.',
|
description: 'Maximum Jimaku search results returned.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'jimaku.autoOpenWhenPrimarySubtitleMissing',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.jimaku.autoOpenWhenPrimarySubtitleMissing,
|
||||||
|
description:
|
||||||
|
'Pause playback and open the Jimaku modal when a local video has no subtitle track in youtube.primarySubLanguages (Japanese by default).',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'tsukihime.apiBaseUrl',
|
path: 'tsukihime.apiBaseUrl',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
import { ConfigOptionRegistryEntry } from './shared';
|
import { ConfigOptionRegistryEntry } from './shared';
|
||||||
import { SUBTITLE_GENERATION_MODELS } from '../../shared/subtitle-generation-model-catalog';
|
import { SUBTITLE_GENERATION_MODELS } from '../../shared/subtitle-generation-model-catalog';
|
||||||
|
import {
|
||||||
|
SUBTITLE_FILTER_PRESET_IDS,
|
||||||
|
SUBTITLE_FILTER_PRESETS,
|
||||||
|
} from '../../shared/subtitle-text-filter';
|
||||||
|
|
||||||
export function buildSubtitleConfigOptionRegistry(
|
export function buildSubtitleConfigOptionRegistry(
|
||||||
defaultConfig: ResolvedConfig,
|
defaultConfig: ResolvedConfig,
|
||||||
@@ -100,6 +104,13 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
|
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.autoPauseVideoOnKeyboardMode',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnKeyboardMode,
|
||||||
|
description:
|
||||||
|
'Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
path: 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
@@ -333,5 +344,24 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.subtitleSidebar.hoverLineBackgroundColor,
|
defaultValue: defaultConfig.subtitleSidebar.hoverLineBackgroundColor,
|
||||||
description: 'Background color for hovered subtitle cues.',
|
description: 'Background color for hovered subtitle cues.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleFilter.presets',
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: defaultConfig.subtitleFilter.presets,
|
||||||
|
description: 'Built-in filters. Each removes its matches and is off by default.',
|
||||||
|
},
|
||||||
|
...SUBTITLE_FILTER_PRESET_IDS.map((id) => ({
|
||||||
|
path: `subtitleFilter.presets.${id}`,
|
||||||
|
kind: 'boolean' as const,
|
||||||
|
defaultValue: defaultConfig.subtitleFilter.presets[id],
|
||||||
|
description: SUBTITLE_FILTER_PRESETS[id].description,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
path: 'subtitleFilter.customRules',
|
||||||
|
kind: 'array',
|
||||||
|
defaultValue: defaultConfig.subtitleFilter.customRules,
|
||||||
|
description:
|
||||||
|
'Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,7 @@ export const SPECIAL_COMMANDS = {
|
|||||||
ANIMETOSHO_OPEN: '__animetosho-open',
|
ANIMETOSHO_OPEN: '__animetosho-open',
|
||||||
TSUKIHIME_OPEN: '__tsukihime-open',
|
TSUKIHIME_OPEN: '__tsukihime-open',
|
||||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||||
|
PLUGIN_ACTION_PREFIX: '__plugin-action:',
|
||||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
||||||
key: 'notifications',
|
key: 'notifications',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Plugins',
|
||||||
|
description: [
|
||||||
|
'JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.',
|
||||||
|
'Plugins run inside SubMiner with full access to your system. Only enable code you trust.',
|
||||||
|
],
|
||||||
|
notes: ['Restart SubMiner after changing plugins.'],
|
||||||
|
key: 'plugins',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Keyboard Shortcuts',
|
title: 'Keyboard Shortcuts',
|
||||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||||
@@ -144,6 +153,15 @@ const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
notes: ['Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.'],
|
notes: ['Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.'],
|
||||||
key: 'subtitleSidebar',
|
key: 'subtitleSidebar',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Subtitle Filters',
|
||||||
|
description: [
|
||||||
|
'Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.',
|
||||||
|
'Applies to the overlay, sidebar, mined sentences and stats.',
|
||||||
|
],
|
||||||
|
notes: ['Hot-reload: filter changes apply to the current subtitle file immediately.'],
|
||||||
|
key: 'subtitleFilter',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const HOT_RELOAD_ROOTS = [
|
|||||||
'shortcuts',
|
'shortcuts',
|
||||||
'subtitleSidebar',
|
'subtitleSidebar',
|
||||||
'subtitleSelection',
|
'subtitleSelection',
|
||||||
|
'subtitleFilter',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||||
|
|||||||
@@ -4,17 +4,21 @@ import { createResolveContext } from './resolve/context';
|
|||||||
import { applyCoreDomainConfig } from './resolve/core-domains';
|
import { applyCoreDomainConfig } from './resolve/core-domains';
|
||||||
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
||||||
import { applyIntegrationConfig } from './resolve/integrations';
|
import { applyIntegrationConfig } from './resolve/integrations';
|
||||||
|
import { applyPluginsConfig } from './resolve/plugins';
|
||||||
import { applyStatsConfig } from './resolve/stats';
|
import { applyStatsConfig } from './resolve/stats';
|
||||||
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
||||||
|
import { applySubtitleFilterConfig } from './resolve/subtitle-filter';
|
||||||
import { applyTopLevelConfig } from './resolve/top-level';
|
import { applyTopLevelConfig } from './resolve/top-level';
|
||||||
|
|
||||||
const APPLY_RESOLVE_STEPS = [
|
const APPLY_RESOLVE_STEPS = [
|
||||||
applyTopLevelConfig,
|
applyTopLevelConfig,
|
||||||
applyCoreDomainConfig,
|
applyCoreDomainConfig,
|
||||||
applySubtitleDomainConfig,
|
applySubtitleDomainConfig,
|
||||||
|
applySubtitleFilterConfig,
|
||||||
applyIntegrationConfig,
|
applyIntegrationConfig,
|
||||||
applyImmersionTrackingConfig,
|
applyImmersionTrackingConfig,
|
||||||
applyStatsConfig,
|
applyStatsConfig,
|
||||||
|
applyPluginsConfig,
|
||||||
applyAnkiConnectResolution,
|
applyAnkiConnectResolution,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
ControllerAxisBinding,
|
ControllerAxisBinding,
|
||||||
ControllerAxisDirection,
|
|
||||||
ControllerButtonBinding,
|
ControllerButtonBinding,
|
||||||
ControllerButtonIndicesConfig,
|
ControllerButtonIndicesConfig,
|
||||||
ControllerDpadFallback,
|
ControllerDpadFallback,
|
||||||
@@ -8,9 +7,20 @@ import type {
|
|||||||
ResolvedControllerBindingsConfig,
|
ResolvedControllerBindingsConfig,
|
||||||
ResolvedControllerDiscreteBinding,
|
ResolvedControllerDiscreteBinding,
|
||||||
} from '../../types/runtime';
|
} from '../../types/runtime';
|
||||||
|
import {
|
||||||
|
isControllerFaceButtonLabels,
|
||||||
|
isControllerScreenAction,
|
||||||
|
parseControllerDiscreteBinding,
|
||||||
|
parseControllerReviewBinding,
|
||||||
|
type ControllerScreenBindings,
|
||||||
|
type ControllerScreenDefinition,
|
||||||
|
} from '../../shared/controller-review';
|
||||||
|
import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
|
||||||
import { ResolveContext } from './context';
|
import { ResolveContext } from './context';
|
||||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||||
|
|
||||||
|
const FACE_BUTTON_LABELS_EXPECTED = "Expected 'xbox', 'nintendo', or 'playstation'.";
|
||||||
|
|
||||||
const CONTROLLER_BUTTON_BINDINGS = [
|
const CONTROLLER_BUTTON_BINDINGS = [
|
||||||
'none',
|
'none',
|
||||||
'select',
|
'select',
|
||||||
@@ -60,7 +70,6 @@ const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
|||||||
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
||||||
leftStickHorizontal: 'horizontal',
|
leftStickHorizontal: 'horizontal',
|
||||||
leftStickVertical: 'vertical',
|
leftStickVertical: 'vertical',
|
||||||
rightStickHorizontal: 'none',
|
|
||||||
rightStickVertical: 'none',
|
rightStickVertical: 'none',
|
||||||
} as const satisfies Record<string, ControllerDpadFallback>;
|
} as const satisfies Record<string, ControllerDpadFallback>;
|
||||||
|
|
||||||
@@ -88,12 +97,19 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
|||||||
'nextAudio',
|
'nextAudio',
|
||||||
'playCurrentAudio',
|
'playCurrentAudio',
|
||||||
'toggleMpvPause',
|
'toggleMpvPause',
|
||||||
|
'holdSecondarySub',
|
||||||
|
'previousEntry',
|
||||||
|
'nextEntry',
|
||||||
|
'toggleCharacterNavigation',
|
||||||
|
'previousSubtitle',
|
||||||
|
'nextSubtitle',
|
||||||
|
'replaySubtitle',
|
||||||
|
'playNextSubtitle',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const CONTROLLER_AXIS_BINDING_KEYS = [
|
const CONTROLLER_AXIS_BINDING_KEYS = [
|
||||||
'leftStickHorizontal',
|
'leftStickHorizontal',
|
||||||
'leftStickVertical',
|
'leftStickVertical',
|
||||||
'rightStickHorizontal',
|
|
||||||
'rightStickVertical',
|
'rightStickVertical',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -102,10 +118,6 @@ const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'pr
|
|||||||
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
|
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
|
||||||
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
||||||
|
|
||||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
|
||||||
return value === 'negative' || value === 'positive';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
||||||
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
||||||
}
|
}
|
||||||
@@ -134,29 +146,6 @@ function resolveLegacyAxisBinding(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
|
||||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
|
||||||
if (value.kind === 'none') {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
if (value.kind === 'button') {
|
|
||||||
return typeof value.buttonIndex === 'number' &&
|
|
||||||
Number.isInteger(value.buttonIndex) &&
|
|
||||||
value.buttonIndex >= 0
|
|
||||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
if (value.kind === 'axis') {
|
|
||||||
return typeof value.axisIndex === 'number' &&
|
|
||||||
Number.isInteger(value.axisIndex) &&
|
|
||||||
value.axisIndex >= 0 &&
|
|
||||||
isControllerAxisDirection(value.direction)
|
|
||||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAxisBindingObject(
|
function parseAxisBindingObject(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||||
@@ -224,7 +213,7 @@ function applyControllerBindings(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
const parsedObject = parseControllerDiscreteBinding(bindingValue);
|
||||||
if (parsedObject) {
|
if (parsedObject) {
|
||||||
target[key] = parsedObject;
|
target[key] = parsedObject;
|
||||||
} else if (bindingValue !== undefined) {
|
} else if (bindingValue !== undefined) {
|
||||||
@@ -232,7 +221,7 @@ function applyControllerBindings(
|
|||||||
`${pathPrefix}.${key}`,
|
`${pathPrefix}.${key}`,
|
||||||
bindingValue,
|
bindingValue,
|
||||||
target[key],
|
target[key],
|
||||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis', and an optional button or axis 'hold'.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,6 +254,37 @@ function applyControllerBindings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyControllerScreenBindings(
|
||||||
|
screen: ControllerScreenDefinition,
|
||||||
|
source: unknown,
|
||||||
|
target: ControllerScreenBindings,
|
||||||
|
pathPrefix: string,
|
||||||
|
warn: ResolveContext['warn'],
|
||||||
|
): void {
|
||||||
|
if (source === undefined) return;
|
||||||
|
if (!isObject(source)) {
|
||||||
|
warn(pathPrefix, source, target, `Expected an object keyed by ${screen.describe} action.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [action, value] of Object.entries(source)) {
|
||||||
|
if (!isControllerScreenAction(screen, action)) {
|
||||||
|
warn(`${pathPrefix}.${action}`, value, undefined, `Unknown ${screen.describe} action.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseControllerReviewBinding(value);
|
||||||
|
if (parsed) {
|
||||||
|
target[action] = parsed;
|
||||||
|
} else {
|
||||||
|
warn(
|
||||||
|
`${pathPrefix}.${action}`,
|
||||||
|
value,
|
||||||
|
target[action],
|
||||||
|
'Expected {"kind": "none"} or {"press": input, "hold"?: input} with button, axis, face or dpad inputs.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function applyControllerConfig(context: ResolveContext): void {
|
export function applyControllerConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
const { src, resolved, warn } = context;
|
||||||
if (!isObject(src.controller)) return;
|
if (!isObject(src.controller)) return;
|
||||||
@@ -319,6 +339,18 @@ export function applyControllerConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const faceButtonLabels = src.controller.faceButtonLabels;
|
||||||
|
if (isControllerFaceButtonLabels(faceButtonLabels)) {
|
||||||
|
resolved.controller.faceButtonLabels = faceButtonLabels;
|
||||||
|
} else if (faceButtonLabels !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.faceButtonLabels',
|
||||||
|
faceButtonLabels,
|
||||||
|
resolved.controller.faceButtonLabels,
|
||||||
|
FACE_BUTTON_LABELS_EXPECTED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const boundedNumberKeys = [
|
const boundedNumberKeys = [
|
||||||
'scrollPixelsPerSecond',
|
'scrollPixelsPerSecond',
|
||||||
'horizontalJumpPixels',
|
'horizontalJumpPixels',
|
||||||
@@ -367,6 +399,15 @@ export function applyControllerConfig(context: ResolveContext): void {
|
|||||||
'controller.bindings',
|
'controller.bindings',
|
||||||
warn,
|
warn,
|
||||||
);
|
);
|
||||||
|
for (const screen of CONTROLLER_SCREENS) {
|
||||||
|
applyControllerScreenBindings(
|
||||||
|
screen,
|
||||||
|
src.controller[screen.configKey],
|
||||||
|
resolved.controller[screen.configKey],
|
||||||
|
`controller.${screen.configKey}`,
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isObject(src.controller.profiles)) {
|
if (isObject(src.controller.profiles)) {
|
||||||
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
|
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
|
||||||
@@ -403,7 +444,21 @@ export function applyControllerConfig(context: ResolveContext): void {
|
|||||||
label: label ?? profileId,
|
label: label ?? profileId,
|
||||||
buttonIndices: structuredClone(resolved.controller.buttonIndices),
|
buttonIndices: structuredClone(resolved.controller.buttonIndices),
|
||||||
bindings: structuredClone(resolved.controller.bindings),
|
bindings: structuredClone(resolved.controller.bindings),
|
||||||
|
faceButtonLabels: resolved.controller.faceButtonLabels,
|
||||||
|
reviewBindings: structuredClone(resolved.controller.reviewBindings),
|
||||||
|
fieldGroupingBindings: structuredClone(resolved.controller.fieldGroupingBindings),
|
||||||
};
|
};
|
||||||
|
// Set per controller from Alt+C; falls back to controller.faceButtonLabels.
|
||||||
|
if (isControllerFaceButtonLabels(rawProfile.faceButtonLabels)) {
|
||||||
|
profile.faceButtonLabels = rawProfile.faceButtonLabels;
|
||||||
|
} else if (rawProfile.faceButtonLabels !== undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.profiles.${profileId}.faceButtonLabels`,
|
||||||
|
rawProfile.faceButtonLabels,
|
||||||
|
profile.faceButtonLabels,
|
||||||
|
FACE_BUTTON_LABELS_EXPECTED,
|
||||||
|
);
|
||||||
|
}
|
||||||
applyControllerButtonIndices(
|
applyControllerButtonIndices(
|
||||||
rawProfile.buttonIndices,
|
rawProfile.buttonIndices,
|
||||||
profile.buttonIndices,
|
profile.buttonIndices,
|
||||||
@@ -417,6 +472,15 @@ export function applyControllerConfig(context: ResolveContext): void {
|
|||||||
`controller.profiles.${profileId}.bindings`,
|
`controller.profiles.${profileId}.bindings`,
|
||||||
warn,
|
warn,
|
||||||
);
|
);
|
||||||
|
for (const screen of CONTROLLER_SCREENS) {
|
||||||
|
applyControllerScreenBindings(
|
||||||
|
screen,
|
||||||
|
rawProfile[screen.configKey],
|
||||||
|
profile[screen.configKey],
|
||||||
|
`controller.profiles.${profileId}.${screen.configKey}`,
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
}
|
||||||
resolved.controller.profiles[profileId] = profile;
|
resolved.controller.profiles[profileId] = profile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,3 +57,18 @@ test('resolveConfig warns for invalid mpv launch mode', () => {
|
|||||||
message: "Expected one of: 'normal', 'maximized', 'fullscreen'.",
|
message: "Expected one of: 'normal', 'maximized', 'fullscreen'.",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveConfig parses the Jimaku auto-open toggle and warns on non-boolean values', () => {
|
||||||
|
const enabled = resolveConfig({ jimaku: { autoOpenWhenPrimarySubtitleMissing: true } });
|
||||||
|
assert.equal(enabled.resolved.jimaku.autoOpenWhenPrimarySubtitleMissing, true);
|
||||||
|
assert.deepEqual(enabled.warnings, []);
|
||||||
|
|
||||||
|
const invalid = resolveConfig({
|
||||||
|
jimaku: { autoOpenWhenPrimarySubtitleMissing: 'sometimes' as never },
|
||||||
|
});
|
||||||
|
assert.equal(invalid.resolved.jimaku.autoOpenWhenPrimarySubtitleMissing, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
invalid.warnings.map((warning) => warning.path),
|
||||||
|
['jimaku.autoOpenWhenPrimarySubtitleMissing'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveConfig } from '../resolve';
|
||||||
|
|
||||||
|
test('resolveConfig keeps plugin entries and their settings', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
plugins: {
|
||||||
|
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
|
||||||
|
dormant: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(resolved.plugins, {
|
||||||
|
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
|
||||||
|
dormant: { enabled: false, settings: {} },
|
||||||
|
});
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveConfig warns about malformed plugin entries and keeps them disabled', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
plugins: {
|
||||||
|
flag: { enabled: 'yes' as never, settings: [] as never },
|
||||||
|
scalar: true as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(resolved.plugins, { flag: { enabled: false, settings: {} } });
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
['plugins.flag.enabled', 'plugins.flag.settings', 'plugins.scalar'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ResolveContext } from './context';
|
||||||
|
import { isObject } from './shared';
|
||||||
|
|
||||||
|
/** Keeps well-formed `plugins.<id>` entries; ids are validated again when the plugin loads. */
|
||||||
|
export function applyPluginsConfig(context: ResolveContext): void {
|
||||||
|
const { src, resolved, warn } = context;
|
||||||
|
if (src.plugins === undefined) return;
|
||||||
|
if (!isObject(src.plugins)) {
|
||||||
|
warn('plugins', src.plugins, resolved.plugins, 'Expected object keyed by plugin id.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, entry] of Object.entries(src.plugins)) {
|
||||||
|
const path = `plugins.${id}`;
|
||||||
|
if (!isObject(entry)) {
|
||||||
|
warn(path, entry, undefined, 'Expected { "enabled": boolean, "settings"?: object }.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') {
|
||||||
|
warn(`${path}.enabled`, entry.enabled, false, 'Expected boolean.');
|
||||||
|
}
|
||||||
|
if (entry.settings !== undefined && !isObject(entry.settings)) {
|
||||||
|
warn(`${path}.settings`, entry.settings, {}, 'Expected object.');
|
||||||
|
}
|
||||||
|
resolved.plugins[id] = {
|
||||||
|
enabled: entry.enabled === true,
|
||||||
|
settings: isObject(entry.settings) ? { ...entry.settings } : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,18 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
'Expected positive number.',
|
'Expected positive number.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoOpen = asBoolean(src.jimaku.autoOpenWhenPrimarySubtitleMissing);
|
||||||
|
if (autoOpen !== undefined) {
|
||||||
|
resolved.jimaku.autoOpenWhenPrimarySubtitleMissing = autoOpen;
|
||||||
|
} else if (src.jimaku.autoOpenWhenPrimarySubtitleMissing !== undefined) {
|
||||||
|
warn(
|
||||||
|
'jimaku.autoOpenWhenPrimarySubtitleMissing',
|
||||||
|
src.jimaku.autoOpenWhenPrimarySubtitleMissing,
|
||||||
|
resolved.jimaku.autoOpenWhenPrimarySubtitleMissing,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTsukihimeSource = isObject(src.tsukihime) ? src.tsukihime : null;
|
const currentTsukihimeSource = isObject(src.tsukihime) ? src.tsukihime : null;
|
||||||
@@ -125,6 +137,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||||
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
|
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
|
||||||
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
|
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
|
||||||
|
const fallbackSubtitleStyleAutoPauseVideoOnKeyboardMode =
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode;
|
||||||
const fallbackSubtitleStylePrimaryVisibleOnYomitanPopup =
|
const fallbackSubtitleStylePrimaryVisibleOnYomitanPopup =
|
||||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup;
|
resolved.subtitleStyle.primaryVisibleOnYomitanPopup;
|
||||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||||
@@ -274,6 +288,23 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawAutoPauseVideoOnKeyboardMode = (
|
||||||
|
src.subtitleStyle as { autoPauseVideoOnKeyboardMode?: unknown }
|
||||||
|
).autoPauseVideoOnKeyboardMode;
|
||||||
|
const autoPauseVideoOnKeyboardMode = asBoolean(rawAutoPauseVideoOnKeyboardMode);
|
||||||
|
if (autoPauseVideoOnKeyboardMode !== undefined) {
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode = autoPauseVideoOnKeyboardMode;
|
||||||
|
} else if (rawAutoPauseVideoOnKeyboardMode !== undefined) {
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode =
|
||||||
|
fallbackSubtitleStyleAutoPauseVideoOnKeyboardMode;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.autoPauseVideoOnKeyboardMode',
|
||||||
|
rawAutoPauseVideoOnKeyboardMode,
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const primaryVisibleOnYomitanPopup = asBoolean(
|
const primaryVisibleOnYomitanPopup = asBoolean(
|
||||||
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
|
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
|
||||||
.primaryVisibleOnYomitanPopup,
|
.primaryVisibleOnYomitanPopup,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { resolveConfig } from '../resolve';
|
||||||
|
|
||||||
|
test('subtitleFilter resolves presets and fills custom rule defaults', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
subtitleFilter: {
|
||||||
|
presets: { parentheses: true },
|
||||||
|
customRules: [
|
||||||
|
{ pattern: '\\n' },
|
||||||
|
{ name: 'tag', pattern: '<i>', replacement: '', enabled: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
assert.equal(resolved.subtitleFilter.presets.parentheses, true);
|
||||||
|
assert.equal(resolved.subtitleFilter.presets.brackets, false);
|
||||||
|
assert.deepEqual(resolved.subtitleFilter.customRules, [
|
||||||
|
{ name: '', pattern: '\\n', replacement: '', enabled: true },
|
||||||
|
{ name: 'tag', pattern: '<i>', replacement: '', enabled: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitleFilter keeps empty and invalid patterns, warns on invalid, drops malformed rules', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
subtitleFilter: {
|
||||||
|
presets: { brackets: 'yes' as unknown as boolean },
|
||||||
|
customRules: [
|
||||||
|
{ name: 'broken', pattern: '(' },
|
||||||
|
{ name: 'placeholder', pattern: '' },
|
||||||
|
{ name: 'no pattern' },
|
||||||
|
'oops' as never,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
resolved.subtitleFilter.customRules.map((rule) => rule.name),
|
||||||
|
['broken', 'placeholder'],
|
||||||
|
);
|
||||||
|
assert.equal(resolved.subtitleFilter.presets.brackets, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
[
|
||||||
|
'subtitleFilter.presets.brackets',
|
||||||
|
'subtitleFilter.customRules[0].pattern',
|
||||||
|
'subtitleFilter.customRules[2]',
|
||||||
|
'subtitleFilter.customRules[3]',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { SubtitleFilterRule } from '../../types/subtitle';
|
||||||
|
import {
|
||||||
|
SUBTITLE_FILTER_PRESET_IDS,
|
||||||
|
getSubtitleFilterPatternError,
|
||||||
|
} from '../../shared/subtitle-text-filter';
|
||||||
|
import { ResolveContext } from './context';
|
||||||
|
import { asBoolean, asString, isObject } from './shared';
|
||||||
|
|
||||||
|
function resolveCustomRule(
|
||||||
|
raw: unknown,
|
||||||
|
index: number,
|
||||||
|
warn: ResolveContext['warn'],
|
||||||
|
): SubtitleFilterRule | null {
|
||||||
|
const path = `subtitleFilter.customRules[${index}]`;
|
||||||
|
const pattern = isObject(raw) ? asString(raw.pattern) : undefined;
|
||||||
|
if (!isObject(raw) || pattern === undefined) {
|
||||||
|
warn(path, raw, null, 'Expected an object with a string pattern.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty and invalid patterns stay in the config so the settings window can show and
|
||||||
|
// fix them; the filter skips both at runtime.
|
||||||
|
const patternError = pattern ? getSubtitleFilterPatternError(pattern) : null;
|
||||||
|
if (patternError !== null) {
|
||||||
|
warn(`${path}.pattern`, pattern, null, `Invalid regular expression: ${patternError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: asString(raw.name) ?? '',
|
||||||
|
pattern,
|
||||||
|
replacement: asString(raw.replacement) ?? '',
|
||||||
|
enabled: asBoolean(raw.enabled) ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySubtitleFilterConfig(context: ResolveContext): void {
|
||||||
|
const { src, resolved, warn } = context;
|
||||||
|
if (src.subtitleFilter === undefined) return;
|
||||||
|
if (!isObject(src.subtitleFilter)) {
|
||||||
|
warn('subtitleFilter', src.subtitleFilter, resolved.subtitleFilter, 'Expected object.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { presets, customRules } = src.subtitleFilter;
|
||||||
|
if (isObject(presets)) {
|
||||||
|
for (const id of SUBTITLE_FILTER_PRESET_IDS) {
|
||||||
|
const enabled = asBoolean(presets[id]);
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
resolved.subtitleFilter.presets[id] = enabled;
|
||||||
|
} else if (presets[id] !== undefined) {
|
||||||
|
warn(
|
||||||
|
`subtitleFilter.presets.${id}`,
|
||||||
|
presets[id],
|
||||||
|
resolved.subtitleFilter.presets[id],
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (presets !== undefined) {
|
||||||
|
warn('subtitleFilter.presets', presets, resolved.subtitleFilter.presets, 'Expected object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(customRules)) {
|
||||||
|
resolved.subtitleFilter.customRules = customRules
|
||||||
|
.map((rule, index) => resolveCustomRule(rule, index, warn))
|
||||||
|
.filter((rule): rule is SubtitleFilterRule => rule !== null);
|
||||||
|
} else if (customRules !== undefined) {
|
||||||
|
warn(
|
||||||
|
'subtitleFilter.customRules',
|
||||||
|
customRules,
|
||||||
|
resolved.subtitleFilter.customRules,
|
||||||
|
'Expected an array of rules.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -346,3 +346,28 @@ test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle autoPauseVideoOnKeyboardMode is off by default and falls back on invalid value', () => {
|
||||||
|
const defaults = createResolveContext({});
|
||||||
|
applySubtitleDomainConfig(defaults.context);
|
||||||
|
assert.equal(defaults.context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, false);
|
||||||
|
|
||||||
|
const enabled = createResolveContext({ subtitleStyle: { autoPauseVideoOnKeyboardMode: true } });
|
||||||
|
applySubtitleDomainConfig(enabled.context);
|
||||||
|
assert.equal(enabled.context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, true);
|
||||||
|
|
||||||
|
const { context, warnings } = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
autoPauseVideoOnKeyboardMode: 'yes' as unknown as boolean,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(context);
|
||||||
|
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, false);
|
||||||
|
assert.ok(
|
||||||
|
warnings.some(
|
||||||
|
(warning) =>
|
||||||
|
warning.path === 'subtitleStyle.autoPauseVideoOnKeyboardMode' &&
|
||||||
|
warning.message === 'Expected boolean.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -6,6 +6,10 @@ import type {
|
|||||||
ConfigSettingsField,
|
ConfigSettingsField,
|
||||||
} from '../../types/settings';
|
} from '../../types/settings';
|
||||||
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
||||||
|
import {
|
||||||
|
SUBTITLE_FILTER_PRESET_IDS,
|
||||||
|
SUBTITLE_FILTER_PRESETS,
|
||||||
|
} from '../../shared/subtitle-text-filter';
|
||||||
import {
|
import {
|
||||||
getSubtitleCssManagedConfigPaths,
|
getSubtitleCssManagedConfigPaths,
|
||||||
getSubtitleCssScopeForPath,
|
getSubtitleCssScopeForPath,
|
||||||
@@ -58,6 +62,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'ankiConnect.isLapis.sentenceCardAudioField',
|
'ankiConnect.isLapis.sentenceCardAudioField',
|
||||||
'ankiConnect.fields.translation',
|
'ankiConnect.fields.translation',
|
||||||
'controller.bindings',
|
'controller.bindings',
|
||||||
|
'controller.reviewBindings',
|
||||||
|
'controller.fieldGroupingBindings',
|
||||||
'controller.preferredGamepadId',
|
'controller.preferredGamepadId',
|
||||||
'controller.preferredGamepadLabel',
|
'controller.preferredGamepadLabel',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
@@ -81,6 +87,7 @@ const JSON_OBJECT_FIELDS = new Set([
|
|||||||
'keybindings',
|
'keybindings',
|
||||||
'controller.bindings',
|
'controller.bindings',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
|
'plugins',
|
||||||
'ankiConnect.knownWords.decks',
|
'ankiConnect.knownWords.decks',
|
||||||
'subtitleStyle.css',
|
'subtitleStyle.css',
|
||||||
'subtitleStyle.secondary.css',
|
'subtitleStyle.secondary.css',
|
||||||
@@ -124,6 +131,7 @@ const SECTION_ORDER = new Map<string, number>(
|
|||||||
'Subtitle Sidebar Appearance',
|
'Subtitle Sidebar Appearance',
|
||||||
'Playback Behavior',
|
'Playback Behavior',
|
||||||
'Subtitle Behavior',
|
'Subtitle Behavior',
|
||||||
|
'Subtitle Filters',
|
||||||
'Subtitle Sidebar Behavior',
|
'Subtitle Sidebar Behavior',
|
||||||
'YouTube Playback Settings',
|
'YouTube Playback Settings',
|
||||||
'mpv Playback',
|
'mpv Playback',
|
||||||
@@ -200,6 +208,7 @@ const PATH_ORDER = new Map<string, number>(
|
|||||||
'logging.files.app',
|
'logging.files.app',
|
||||||
'logging.files.launcher',
|
'logging.files.launcher',
|
||||||
'logging.files.mpv',
|
'logging.files.mpv',
|
||||||
|
...SUBTITLE_FILTER_PRESET_IDS.map((id) => `subtitleFilter.presets.${id}`),
|
||||||
].map((path, index) => [path, index]),
|
].map((path, index) => [path, index]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -230,6 +239,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
|
||||||
|
'subtitleStyle.autoPauseVideoOnKeyboardMode': 'Pause Video In Keyboard-Only Mode',
|
||||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
|
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
|
||||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||||
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||||
@@ -249,6 +259,12 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||||
'ankiConnect.media.reviewTiming': 'Review Media Timing',
|
'ankiConnect.media.reviewTiming': 'Review Media Timing',
|
||||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||||
|
...Object.fromEntries(
|
||||||
|
SUBTITLE_FILTER_PRESET_IDS.map((id) => [
|
||||||
|
`subtitleFilter.presets.${id}`,
|
||||||
|
SUBTITLE_FILTER_PRESETS[id].label,
|
||||||
|
]),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||||
@@ -276,6 +292,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
|||||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||||
'discordPresence.updateIntervalMs':
|
'discordPresence.updateIntervalMs':
|
||||||
'Minimum interval between presence payload updates, in milliseconds.',
|
'Minimum interval between presence payload updates, in milliseconds.',
|
||||||
|
'subtitleFilter.customRules':
|
||||||
|
'Your own regex rules, applied in order after the presets. ^ and $ match each line. Leave the replacement empty to remove the match.',
|
||||||
};
|
};
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -341,6 +359,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (
|
if (
|
||||||
path === 'subtitleStyle.autoPauseVideoOnHover' ||
|
path === 'subtitleStyle.autoPauseVideoOnHover' ||
|
||||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||||
|
path === 'subtitleStyle.autoPauseVideoOnKeyboardMode' ||
|
||||||
path === 'subtitleSidebar.pauseVideoOnHover'
|
path === 'subtitleSidebar.pauseVideoOnHover'
|
||||||
) {
|
) {
|
||||||
return { category: 'behavior', section: 'Playback Behavior' };
|
return { category: 'behavior', section: 'Playback Behavior' };
|
||||||
@@ -348,6 +367,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path === 'subtitleStyle.preserveLineBreaks') {
|
if (path === 'subtitleStyle.preserveLineBreaks') {
|
||||||
return { category: 'behavior', section: 'Subtitle Behavior' };
|
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('subtitleFilter.')) {
|
||||||
|
return { category: 'behavior', section: 'Subtitle Filters' };
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
|
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
|
||||||
path === 'ankiConnect.knownWords.decks' ||
|
path === 'ankiConnect.knownWords.decks' ||
|
||||||
@@ -441,6 +463,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('notifications.')) {
|
if (path.startsWith('notifications.')) {
|
||||||
return { category: 'behavior', section: 'Notifications' };
|
return { category: 'behavior', section: 'Notifications' };
|
||||||
}
|
}
|
||||||
|
if (path === 'plugins') {
|
||||||
|
return { category: 'advanced', section: 'Plugins' };
|
||||||
|
}
|
||||||
if (path === 'mpv.aniskipButtonKey') {
|
if (path === 'mpv.aniskipButtonKey') {
|
||||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||||
}
|
}
|
||||||
@@ -549,6 +574,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
|||||||
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
|
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
|
||||||
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'color';
|
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'color';
|
||||||
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
|
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
|
||||||
|
if (path === 'subtitleFilter.customRules') return 'subtitle-filter-rules';
|
||||||
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
||||||
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
||||||
if (Array.isArray(value)) return 'string-list';
|
if (Array.isArray(value)) return 'string-list';
|
||||||
|
|||||||
@@ -155,15 +155,20 @@ const WATCH_TIME_PER_ANIME = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TRENDS_DASHBOARD = {
|
const TRENDS_DASHBOARD = {
|
||||||
|
window: { startDay: 20_000, endDay: 20_000 },
|
||||||
|
buckets: [{ label: 'Mar 1', startDay: 20_000, endDay: 20_000 }],
|
||||||
|
summary: {
|
||||||
|
current: { watchTimeMin: 25, words: 300, newWords: 12, cards: 5, episodes: 2, activeDays: 1 },
|
||||||
|
previous: null,
|
||||||
|
},
|
||||||
activity: {
|
activity: {
|
||||||
watchTime: [{ label: 'Mar 1', value: 25 }],
|
watchTime: [{ label: 'Mar 1', value: 25 }],
|
||||||
cards: [{ label: 'Mar 1', value: 5 }],
|
cards: [{ label: 'Mar 1', value: 5 }],
|
||||||
words: [{ label: 'Mar 1', value: 300 }],
|
words: [{ label: 'Mar 1', value: 300 }],
|
||||||
sessions: [{ label: 'Mar 1', value: 3 }],
|
episodes: [{ label: 'Mar 1', value: 2 }],
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
watchTime: [{ label: 'Mar 1', value: 25 }],
|
watchTime: [{ label: 'Mar 1', value: 25 }],
|
||||||
sessions: [{ label: 'Mar 1', value: 3 }],
|
|
||||||
words: [{ label: 'Mar 1', value: 300 }],
|
words: [{ label: 'Mar 1', value: 300 }],
|
||||||
newWords: [{ label: 'Mar 1', value: 12 }],
|
newWords: [{ label: 'Mar 1', value: 12 }],
|
||||||
cards: [{ label: 'Mar 1', value: 5 }],
|
cards: [{ label: 'Mar 1', value: 5 }],
|
||||||
@@ -175,11 +180,19 @@ const TRENDS_DASHBOARD = {
|
|||||||
cardsPerHour: [{ label: 'Mar 1', value: 12 }],
|
cardsPerHour: [{ label: 'Mar 1', value: 12 }],
|
||||||
readingSpeed: [{ label: 'Mar 1', value: 180 }],
|
readingSpeed: [{ label: 'Mar 1', value: 180 }],
|
||||||
},
|
},
|
||||||
|
learning: {
|
||||||
|
newWordsPerThousand: [{ label: 'Mar 1', value: null }],
|
||||||
|
frequencyBands: [],
|
||||||
|
},
|
||||||
|
watchTimeByDayHour: [],
|
||||||
librarySummary: [
|
librarySummary: [
|
||||||
{
|
{
|
||||||
title: 'Little Witch Academia',
|
title: 'Little Witch Academia',
|
||||||
|
animeId: 1,
|
||||||
|
videoId: 1,
|
||||||
watchTimeMin: 25,
|
watchTimeMin: 25,
|
||||||
videos: 1,
|
videos: 1,
|
||||||
|
episodesFinished: 1,
|
||||||
sessions: 1,
|
sessions: 1,
|
||||||
cards: 5,
|
cards: 5,
|
||||||
words: 300,
|
words: 300,
|
||||||
@@ -189,16 +202,7 @@ const TRENDS_DASHBOARD = {
|
|||||||
lastWatched: 20_000,
|
lastWatched: 20_000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
animeCumulative: {
|
titleTimeline: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
||||||
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
|
||||||
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
|
||||||
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
|
||||||
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
|
|
||||||
},
|
|
||||||
patterns: {
|
|
||||||
watchTimeByDayOfWeek: [{ label: 'Sun', value: 25 }],
|
|
||||||
watchTimeByHour: [{ label: '12:00', value: 25 }],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ANIME_EPISODES = [
|
const ANIME_EPISODES = [
|
||||||
@@ -227,6 +231,12 @@ const WORD_DETAIL = {
|
|||||||
pos2: '一般',
|
pos2: '一般',
|
||||||
pos3: null,
|
pos3: null,
|
||||||
frequency: 42,
|
frequency: 42,
|
||||||
|
frequencyRank: 100,
|
||||||
|
dictionaryReading: 'ねこ',
|
||||||
|
formCount: 1,
|
||||||
|
forms: [{ wordId: 1, word: '猫', reading: 'ねこ', seen: 42 }],
|
||||||
|
lineCount: 40,
|
||||||
|
streamLineCount: 0,
|
||||||
firstSeen: Date.now() - 100_000,
|
firstSeen: Date.now() - 100_000,
|
||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -313,11 +323,11 @@ function createMockTracker(
|
|||||||
}),
|
}),
|
||||||
getVocabularyChartData: async () => ({
|
getVocabularyChartData: async () => ({
|
||||||
ready: true,
|
ready: true,
|
||||||
topWords: [{ wordId: 1, headword: 'する', frequency: 50 }],
|
|
||||||
topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }],
|
|
||||||
newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }],
|
newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }],
|
||||||
newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }],
|
newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }],
|
||||||
}),
|
}),
|
||||||
|
getDictionaryWords: async () => ({ rows: [], total: 0, maxSeen: 0 }),
|
||||||
|
getVocabularyCoverage: async () => [],
|
||||||
getStatsExcludedWords: async () => [],
|
getStatsExcludedWords: async () => [],
|
||||||
replaceStatsExcludedWords: async () => {},
|
replaceStatsExcludedWords: async () => {},
|
||||||
getKanjiStats: async () => KANJI_STATS,
|
getKanjiStats: async () => KANJI_STATS,
|
||||||
@@ -674,9 +684,16 @@ const CLAMPED_QUERIES: Array<{
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TRENDS_DASHBOARD_QUERIES = [
|
const TRENDS_DASHBOARD_QUERIES = [
|
||||||
{ query: 'range=90d&groupBy=month', expectedArgs: ['90d', 'month', true] },
|
{ query: 'range=90d&groupBy=month', expectedArgs: ['90d', 'month', true, null] },
|
||||||
{ query: 'range=weird&groupBy=year', expectedArgs: ['30d', 'day', true] },
|
{ query: 'range=90d&groupBy=week', expectedArgs: ['90d', 'week', true, null] },
|
||||||
{ query: 'range=30d&groupBy=day&fillEmpty=false', expectedArgs: ['30d', 'day', false] },
|
{ query: 'range=weird&groupBy=year', expectedArgs: ['30d', 'day', true, null] },
|
||||||
|
{ query: 'range=30d&groupBy=day&fillEmpty=false', expectedArgs: ['30d', 'day', false, null] },
|
||||||
|
{
|
||||||
|
query: 'range=custom&fromDay=20000&toDay=20010',
|
||||||
|
expectedArgs: [{ startDay: 20000, endDay: 20010 }, 'day', true, null],
|
||||||
|
},
|
||||||
|
{ query: 'range=custom&fromDay=20010&toDay=20000', expectedArgs: ['30d', 'day', true, null] },
|
||||||
|
{ query: 'range=30d&title=Bocchi%20%26%20Co', expectedArgs: ['30d', 'day', true, 'Bocchi & Co'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MALFORMED_IDS = [
|
const MALFORMED_IDS = [
|
||||||
@@ -991,7 +1008,7 @@ describe('stats server API routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const { query, expectedArgs } of TRENDS_DASHBOARD_QUERIES) {
|
for (const { query, expectedArgs } of TRENDS_DASHBOARD_QUERIES) {
|
||||||
it(`GET /api/stats/trends/dashboard?${query} requests ${expectedArgs.join('/')}`, async () => {
|
it(`GET /api/stats/trends/dashboard?${query} requests ${JSON.stringify(expectedArgs)}`, async () => {
|
||||||
let seenArgs: unknown[] = [];
|
let seenArgs: unknown[] = [];
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
createMockTracker({
|
createMockTracker({
|
||||||
@@ -1008,6 +1025,69 @@ describe('stats server API routes', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('GET /api/stats/vocabulary/words passes a parsed query and ignores unknown values', async () => {
|
||||||
|
const seenQueries: unknown[] = [];
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getDictionaryWords: async (_knownWords: unknown, query: unknown) => {
|
||||||
|
seenQueries.push(query);
|
||||||
|
return { rows: [], total: 0, maxSeen: 0 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.request(
|
||||||
|
'/api/stats/vocabulary/words?search=%20%E7%8C%AB%20&sort=rank&order=asc&hideKnown=true&hideNames=true&pos=verb&maxRank=10000&minTitles=2&limit=25&offset=50',
|
||||||
|
);
|
||||||
|
await app.request('/api/stats/vocabulary/words?sort=bogus&pos=bogus&maxRank=-1&limit=99999');
|
||||||
|
|
||||||
|
assert.deepEqual(seenQueries, [
|
||||||
|
{
|
||||||
|
search: '猫',
|
||||||
|
sort: 'rank',
|
||||||
|
descending: false,
|
||||||
|
hideKnown: true,
|
||||||
|
hideNames: true,
|
||||||
|
hideKana: false,
|
||||||
|
partOfSpeech: 'verb',
|
||||||
|
maxRank: 10000,
|
||||||
|
minTitles: 2,
|
||||||
|
limit: 25,
|
||||||
|
offset: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
search: undefined,
|
||||||
|
sort: 'seen',
|
||||||
|
descending: true,
|
||||||
|
hideKnown: false,
|
||||||
|
hideNames: false,
|
||||||
|
hideKana: false,
|
||||||
|
partOfSpeech: undefined,
|
||||||
|
maxRank: undefined,
|
||||||
|
minTitles: undefined,
|
||||||
|
limit: 200,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/sessions narrows to a valid day range and ignores a reversed one', async () => {
|
||||||
|
const seenRanges: unknown[] = [];
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getSessionSummaries: async (_limit?: number, dayRange?: unknown) => {
|
||||||
|
seenRanges.push(dayRange);
|
||||||
|
return SESSION_SUMMARIES;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.request('/api/stats/sessions?limit=500&fromDay=20000&toDay=20006');
|
||||||
|
await app.request('/api/stats/sessions?fromDay=20006&toDay=20000');
|
||||||
|
|
||||||
|
assert.deepEqual(seenRanges, [{ startDay: 20000, endDay: 20006 }, undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
|
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
|
||||||
let seenArgs: unknown[] = [];
|
let seenArgs: unknown[] = [];
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
@@ -1026,7 +1106,7 @@ describe('stats server API routes', () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
assert.ok(Array.isArray(body));
|
assert.ok(Array.isArray(body));
|
||||||
assert.equal(body[0].animeTitle, 'Little Witch Academia');
|
assert.equal(body[0].animeTitle, 'Little Witch Academia');
|
||||||
assert.deepEqual(seenArgs, ['猫', '猫', 'ねこ', 500, 25]);
|
assert.deepEqual(seenArgs, ['猫', '猫', 'ねこ', 500, 25, false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/stats/kanji/occurrences returns recent occurrence rows for a kanji', async () => {
|
it('GET /api/stats/kanji/occurrences returns recent occurrence rows for a kanji', async () => {
|
||||||
@@ -1056,6 +1136,33 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(res.status, 400);
|
assert.equal(res.status, 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/vocabulary/occurrences finds all forms of legacy words missing text', async () => {
|
||||||
|
const seenHeadwords: unknown[] = [];
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getWordOccurrences: async (...args: unknown[]) => {
|
||||||
|
seenHeadwords.push(args[0]);
|
||||||
|
return OCCURRENCES;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const request = (params: string) =>
|
||||||
|
app.request(`/api/stats/vocabulary/occurrences?allForms=true&${params}`);
|
||||||
|
|
||||||
|
assert.equal((await request('headword=&word=%E7%8C%AB&reading=')).status, 200);
|
||||||
|
assert.equal((await request('headword=%E7%8A%AC&word=&reading=')).status, 200);
|
||||||
|
assert.equal((await request('headword=&word=&reading=%E3%81%AD%E3%81%93')).status, 200);
|
||||||
|
assert.deepEqual(seenHeadwords, ['猫', '犬', 'ねこ']);
|
||||||
|
|
||||||
|
assert.equal((await request('headword=%20&word=&reading=')).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/vocabulary/occurrences needs a headword and form for an exact lookup', async () => {
|
||||||
|
const app = createStatsApp(createMockTracker());
|
||||||
|
const res = await app.request('/api/stats/vocabulary/occurrences?headword=&word=%E7%8C%AB');
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
it('PUT /api/stats/excluded-words replaces tracker exclusion rows', async () => {
|
it('PUT /api/stats/excluded-words replaces tracker exclusion rows', async () => {
|
||||||
let seenWords: unknown = null;
|
let seenWords: unknown = null;
|
||||||
const app = createStatsApp(
|
const app = createStatsApp(
|
||||||
|
|||||||
@@ -401,7 +401,83 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => {
|
test('updateAnilistPostWatchProgress adds an exactly matched show missing from the list', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let call = 0;
|
||||||
|
let saveVariables: Record<string, unknown> | undefined;
|
||||||
|
globalThis.fetch = (async (_input, init) => {
|
||||||
|
call += 1;
|
||||||
|
if (call === 1) {
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [{ id: 33, episodes: 12, title: { english: 'New Show' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (call === 2) {
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Media: { id: 33, mediaListEntry: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveVariables = (JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> })
|
||||||
|
.variables;
|
||||||
|
return createJsonResponse({
|
||||||
|
data: { SaveMediaListEntry: { progress: 1, status: 'CURRENT' } },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateAnilistPostWatchProgress('token', 'New Show', 1);
|
||||||
|
assert.equal(result.status, 'updated');
|
||||||
|
assert.match(result.message, /added "New Show" to Watching at episode 1/i);
|
||||||
|
assert.deepEqual(saveVariables, { mediaId: 33, progress: 1, status: 'CURRENT' });
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAnilistPostWatchProgress adds a pinned media id missing from the list', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let call = 0;
|
||||||
|
let saveVariables: Record<string, unknown> | undefined;
|
||||||
|
globalThis.fetch = (async (_input, init) => {
|
||||||
|
call += 1;
|
||||||
|
if (call === 1) {
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Media: {
|
||||||
|
id: 108489,
|
||||||
|
episodes: 12,
|
||||||
|
title: { english: 'Pinned Show' },
|
||||||
|
mediaListEntry: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveVariables = (JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> })
|
||||||
|
.variables;
|
||||||
|
return createJsonResponse({
|
||||||
|
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateAnilistPostWatchProgress('token', 'Wrong Guess', 12, {
|
||||||
|
mediaId: 108489,
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 'updated');
|
||||||
|
assert.match(result.message, /added "Pinned Show" at episode 12 and marked it completed/i);
|
||||||
|
assert.deepEqual(saveVariables, { mediaId: 108489, progress: 12, status: 'COMPLETED' });
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAnilistPostWatchProgress does not add a loosely matched show missing from the list', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let call = 0;
|
let call = 0;
|
||||||
globalThis.fetch = (async () => {
|
globalThis.fetch = (async () => {
|
||||||
@@ -410,7 +486,7 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n
|
|||||||
return createJsonResponse({
|
return createJsonResponse({
|
||||||
data: {
|
data: {
|
||||||
Page: {
|
Page: {
|
||||||
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }],
|
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show Abridged Edition' } }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -427,6 +503,39 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n
|
|||||||
assert.equal(result.status, 'error');
|
assert.equal(result.status, 'error');
|
||||||
assert.equal(result.retryable, false);
|
assert.equal(result.retryable, false);
|
||||||
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||||
|
assert.equal(call, 2);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAnilistPostWatchProgress does not add the season 1 entry when the title carries an unparsed season marker', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let call = 0;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
call += 1;
|
||||||
|
if (call === 1) {
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [{ id: 33, episodes: 12, format: 'TV', title: { english: 'Demo Show' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Media: { id: 33, mediaListEntry: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateAnilistPostWatchProgress('token', 'Demo Show S2', 3);
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.equal(result.retryable, false);
|
||||||
|
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||||
|
assert.equal(call, 2);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
|||||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||||
import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity';
|
import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity';
|
||||||
import type { AnilistRateLimiter } from './rate-limiter';
|
import type { AnilistRateLimiter } from './rate-limiter';
|
||||||
import { resolveAnilistSeasonMedia } from './season-resolver';
|
import { resolveAnilistSeasonMedia, stripSeasonSuffix } from './season-resolver';
|
||||||
|
|
||||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
|
|
||||||
@@ -273,6 +273,9 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
let mediaId = pinnedMediaId;
|
let mediaId = pinnedMediaId;
|
||||||
let resolvedTitle: string | null = null;
|
let resolvedTitle: string | null = null;
|
||||||
let resolvedEpisodes: number | null = null;
|
let resolvedEpisodes: number | null = null;
|
||||||
|
// A missing list entry is only created when we trust the match; otherwise a fuzzy
|
||||||
|
// search hit would add an unrelated show to the user's list.
|
||||||
|
let confidentMatch = pinnedMediaId !== null;
|
||||||
|
|
||||||
if (mediaId === null) {
|
if (mediaId === null) {
|
||||||
let resolution: Awaited<ReturnType<typeof resolveAnilistSeasonMedia>>;
|
let resolution: Awaited<ReturnType<typeof resolveAnilistSeasonMedia>>;
|
||||||
@@ -312,6 +315,11 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
mediaId = resolution.id;
|
mediaId = resolution.id;
|
||||||
resolvedTitle = resolution.title;
|
resolvedTitle = resolution.title;
|
||||||
resolvedEpisodes = resolution.episodes;
|
resolvedEpisodes = resolution.episodes;
|
||||||
|
// With no parsed season, an exact match on a season-stripped title ("Show S2" ->
|
||||||
|
// "Show") only certifies the season 1 anchor, not the entry being watched.
|
||||||
|
const seasonMarkerStripped = stripSeasonSuffix(title).trim() !== title.trim();
|
||||||
|
confidentMatch =
|
||||||
|
resolution.exactTitleMatch && (resolution.requestedSeason !== null || !seasonMarkerStripped);
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
|
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
|
||||||
@@ -358,7 +366,8 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
: null);
|
: null);
|
||||||
|
|
||||||
const entry = entryMedia?.mediaListEntry ?? null;
|
const entry = entryMedia?.mediaListEntry ?? null;
|
||||||
if (!entry || !isUpdateableListStatus(entry.status)) {
|
const createEntry = !entry && confidentMatch;
|
||||||
|
if (!createEntry && (!entry || !isUpdateableListStatus(entry.status))) {
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
retryable: false,
|
retryable: false,
|
||||||
@@ -366,7 +375,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentProgress = entry.progress ?? 0;
|
const currentProgress = entry?.progress ?? 0;
|
||||||
const shouldMarkCompleted = isKnownFinalEpisode(pickedEpisodes, episode);
|
const shouldMarkCompleted = isKnownFinalEpisode(pickedEpisodes, episode);
|
||||||
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
|
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
|
||||||
return {
|
return {
|
||||||
@@ -397,6 +406,15 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
return { status: 'error', message: `AniList update failed: ${saveError}` };
|
return { status: 'error', message: `AniList update failed: ${saveError}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (createEntry) {
|
||||||
|
return {
|
||||||
|
status: 'updated',
|
||||||
|
message: shouldMarkCompleted
|
||||||
|
? `AniList added "${pickedTitle}" at episode ${episode} and marked it completed.`
|
||||||
|
: `AniList added "${pickedTitle}" to Watching at episode ${episode}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'updated',
|
status: 'updated',
|
||||||
message: shouldMarkCompleted
|
message: shouldMarkCompleted
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
|||||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||||
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
||||||
createMpvClient: () => calls.push('createMpvClient'),
|
createMpvClient: () => calls.push('createMpvClient'),
|
||||||
|
startPlugins: () => calls.push('startPlugins'),
|
||||||
reloadConfig: () => calls.push('reloadConfig'),
|
reloadConfig: () => calls.push('reloadConfig'),
|
||||||
getResolvedConfig: () => ({
|
getResolvedConfig: () => ({
|
||||||
websocket: { enabled: 'auto' },
|
websocket: { enabled: 'auto' },
|
||||||
@@ -53,7 +54,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
|||||||
startBackgroundWarmups: () => {
|
startBackgroundWarmups: () => {
|
||||||
calls.push('startBackgroundWarmups');
|
calls.push('startBackgroundWarmups');
|
||||||
},
|
},
|
||||||
texthookerOnlyMode: false,
|
isTexthookerOnlyMode: () => false,
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||||
setVisibleOverlayVisible: (visible) => calls.push(`setVisibleOverlayVisible:${visible}`),
|
setVisibleOverlayVisible: (visible) => calls.push(`setVisibleOverlayVisible:${visible}`),
|
||||||
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
|
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
|
||||||
@@ -162,6 +163,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
|||||||
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
||||||
assert.equal(calls.includes('resolveKeybindings'), false);
|
assert.equal(calls.includes('resolveKeybindings'), false);
|
||||||
assert.equal(calls.includes('createMpvClient'), false);
|
assert.equal(calls.includes('createMpvClient'), false);
|
||||||
|
assert.equal(calls.includes('startPlugins'), false);
|
||||||
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
||||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
assert.equal(calls.includes('createImmersionTracker'), false);
|
||||||
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
||||||
@@ -178,7 +180,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
|||||||
|
|
||||||
test('runAppReadyRuntime keeps websocket startup in texthooker-only mode but skips overlay window', async () => {
|
test('runAppReadyRuntime keeps websocket startup in texthooker-only mode but skips overlay window', async () => {
|
||||||
const { deps, calls } = makeDeps({
|
const { deps, calls } = makeDeps({
|
||||||
texthookerOnlyMode: true,
|
isTexthookerOnlyMode: () => true,
|
||||||
reloadConfig: () => calls.push('reloadConfig'),
|
reloadConfig: () => calls.push('reloadConfig'),
|
||||||
handleInitialArgs: () => calls.push('handleInitialArgs'),
|
handleInitialArgs: () => calls.push('handleInitialArgs'),
|
||||||
});
|
});
|
||||||
@@ -326,6 +328,9 @@ test('runAppReadyRuntime starts background warmups after overlay startup', async
|
|||||||
|
|
||||||
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
||||||
|
assert.ok(calls.includes('setLogLevel:info:config'));
|
||||||
|
assert.ok(calls.indexOf('setLogLevel:info:config') < calls.indexOf('startPlugins'));
|
||||||
|
assert.ok(calls.indexOf('startPlugins') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -737,6 +737,23 @@ test('handleCliCommand dispatches generic session action payloads', async () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand runs plugin actions through the session action dispatcher', async () => {
|
||||||
|
let request: unknown = null;
|
||||||
|
const { deps } = createDeps({
|
||||||
|
dispatchSessionAction: async (nextRequest) => {
|
||||||
|
request = nextRequest;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(makeArgs({ pluginAction: 'anime/open' }), 'initial', deps);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
actionId: 'runPluginAction',
|
||||||
|
payload: { pluginActionId: 'anime/open' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('handleCliCommand dispatches mark-watched session action', async () => {
|
test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||||
let request: unknown = null;
|
let request: unknown = null;
|
||||||
const { deps } = createDeps({
|
const { deps } = createDeps({
|
||||||
|
|||||||
@@ -422,6 +422,12 @@ export function handleCliCommand(
|
|||||||
`sessionAction:${args.sessionAction.actionId}`,
|
`sessionAction:${args.sessionAction.actionId}`,
|
||||||
'Session action failed',
|
'Session action failed',
|
||||||
);
|
);
|
||||||
|
} else if (args.pluginAction) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'runPluginAction', payload: { pluginActionId: args.pluginAction } },
|
||||||
|
`pluginAction:${args.pluginAction}`,
|
||||||
|
'Plugin action failed',
|
||||||
|
);
|
||||||
} else if (args.toggle || args.toggleVisibleOverlay) {
|
} else if (args.toggle || args.toggleVisibleOverlay) {
|
||||||
deps.toggleVisibleOverlay();
|
deps.toggleVisibleOverlay();
|
||||||
} else if (args.togglePrimarySubtitleBar) {
|
} else if (args.togglePrimarySubtitleBar) {
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ import {
|
|||||||
getSessionWordsByLine,
|
getSessionWordsByLine,
|
||||||
} from './immersion-tracker/query-sessions';
|
} from './immersion-tracker/query-sessions';
|
||||||
import { getTrendsDashboard } from './immersion-tracker/query-trends';
|
import { getTrendsDashboard } from './immersion-tracker/query-trends';
|
||||||
|
import {
|
||||||
|
getDictionaryWordPage,
|
||||||
|
getVocabularyCoverage,
|
||||||
|
} from './immersion-tracker/query-vocabulary-words';
|
||||||
|
import type {
|
||||||
|
StatsDayRange,
|
||||||
|
StatsDictionaryWordQuery,
|
||||||
|
StatsTrendGroupBy,
|
||||||
|
StatsTrendRange,
|
||||||
|
} from '../../types/stats-http-contract';
|
||||||
import {
|
import {
|
||||||
getKanjiAnimeAppearances,
|
getKanjiAnimeAppearances,
|
||||||
getKanjiDetail,
|
getKanjiDetail,
|
||||||
@@ -618,8 +628,11 @@ export class ImmersionTrackerService {
|
|||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSessionSummaries(limit = 50): Promise<SessionSummaryQueryRow[]> {
|
async getSessionSummaries(
|
||||||
return getSessionSummaries(this.db, limit);
|
limit = 50,
|
||||||
|
dayRange?: StatsDayRange,
|
||||||
|
): Promise<SessionSummaryQueryRow[]> {
|
||||||
|
return getSessionSummaries(this.db, limit, dayRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSessionTimeline(sessionId: number, limit?: number): Promise<SessionTimelineRow[]> {
|
async getSessionTimeline(sessionId: number, limit?: number): Promise<SessionTimelineRow[]> {
|
||||||
@@ -673,11 +686,12 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTrendsDashboard(
|
async getTrendsDashboard(
|
||||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
range: StatsTrendRange = '30d',
|
||||||
groupBy: 'day' | 'month' = 'day',
|
groupBy: StatsTrendGroupBy = 'day',
|
||||||
fillEmptyBuckets = true,
|
fillEmptyBuckets = true,
|
||||||
|
title: string | null = null,
|
||||||
) {
|
) {
|
||||||
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
|
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
|
||||||
@@ -703,6 +717,17 @@ export class ImmersionTrackerService {
|
|||||||
return getVocabularyChartData(this.db);
|
return getVocabularyChartData(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDictionaryWords(
|
||||||
|
knownWords: ReadonlySet<string> | null,
|
||||||
|
query: StatsDictionaryWordQuery,
|
||||||
|
) {
|
||||||
|
return getDictionaryWordPage(this.db, knownWords, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVocabularyCoverage(knownWords: ReadonlySet<string>) {
|
||||||
|
return getVocabularyCoverage(this.db, knownWords);
|
||||||
|
}
|
||||||
|
|
||||||
async getStatsExcludedWords(): Promise<StatsExcludedWordRow[]> {
|
async getStatsExcludedWords(): Promise<StatsExcludedWordRow[]> {
|
||||||
return getStatsExcludedWords(this.db);
|
return getStatsExcludedWords(this.db);
|
||||||
}
|
}
|
||||||
@@ -754,8 +779,9 @@ export class ImmersionTrackerService {
|
|||||||
reading: string,
|
reading: string,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
allForms = false,
|
||||||
): Promise<WordOccurrenceRow[]> {
|
): Promise<WordOccurrenceRow[]> {
|
||||||
return getWordOccurrences(this.db, headword, word, reading, limit, offset);
|
return getWordOccurrences(this.db, headword, word, reading, limit, offset, allForms);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getKanjiOccurrences(kanji: string, limit = 100, offset = 0): Promise<KanjiOccurrenceRow[]> {
|
async getKanjiOccurrences(kanji: string, limit = 100, offset = 0): Promise<KanjiOccurrenceRow[]> {
|
||||||
|
|||||||
@@ -704,7 +704,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
total_tokens_seen, total_cards
|
total_tokens_seen, total_cards
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(Math.floor(dayOneStart / 86_400_000), videoId, 1, 30, 10, 120, 2);
|
).run(getLocalEpochDay(db, dayOneStart), videoId, 1, 30, 10, 120, 2);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -713,7 +713,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
total_tokens_seen, total_cards
|
total_tokens_seen, total_cards
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(Math.floor(dayTwoStart / 86_400_000), videoId, 1, 45, 10, 140, 3);
|
).run(getLocalEpochDay(db, dayTwoStart), videoId, 1, 45, 10, 140, 3);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -739,15 +739,17 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
assert.equal(dashboard.activity.watchTime[0]?.value, 30);
|
assert.equal(dashboard.activity.watchTime[0]?.value, 30);
|
||||||
assert.equal(dashboard.progress.watchTime[1]?.value, 75);
|
assert.equal(dashboard.progress.watchTime[1]?.value, 75);
|
||||||
assert.equal(dashboard.progress.lookups[1]?.value, 18);
|
assert.equal(dashboard.progress.lookups[1]?.value, 18);
|
||||||
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
|
|
||||||
assert.equal(dashboard.ratios.cardsPerHour[0]?.value, +(2 / (30 / 60)).toFixed(1));
|
assert.equal(dashboard.ratios.cardsPerHour[0]?.value, +(2 / (30 / 60)).toFixed(1));
|
||||||
assert.equal(dashboard.ratios.cardsPerHour[1]?.value, +(3 / (45 / 60)).toFixed(1));
|
assert.equal(dashboard.ratios.cardsPerHour[1]?.value, +(5 / (75 / 60)).toFixed(1));
|
||||||
assert.equal(dashboard.ratios.readingSpeed[0]?.value, +(120 / 30).toFixed(1));
|
assert.equal(dashboard.ratios.readingSpeed[0]?.value, +(120 / 30).toFixed(1));
|
||||||
assert.equal(dashboard.ratios.readingSpeed[1]?.value, +(140 / 45).toFixed(1));
|
assert.equal(dashboard.ratios.readingSpeed[1]?.value, +(260 / 75).toFixed(1));
|
||||||
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
|
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
|
||||||
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
|
assert.deepEqual(
|
||||||
|
dashboard.titleTimeline.map((point) => point.value).sort((a, b) => a - b),
|
||||||
|
[30, 45],
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
|
dashboard.watchTimeByDayHour.flat().reduce((sum, minutes) => sum + minutes, 0),
|
||||||
75,
|
75,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -819,7 +821,7 @@ test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
|||||||
|
|
||||||
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||||
const titles = [
|
const titles = [
|
||||||
...dashboard.animeCumulative.watchTime.map((point) => point.animeTitle),
|
...dashboard.titleTimeline.map((point) => point.animeTitle),
|
||||||
...dashboard.librarySummary.map((row) => row.title),
|
...dashboard.librarySummary.map((row) => row.title),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1521,6 +1523,8 @@ test('word-count read models use filtered persisted occurrences with raw fallbac
|
|||||||
const startedAtMs = 1_700_000_000_000;
|
const startedAtMs = 1_700_000_000_000;
|
||||||
const withOccurrences = startSessionRecord(db, videoId, startedAtMs);
|
const withOccurrences = startSessionRecord(db, videoId, startedAtMs);
|
||||||
const fallbackOnly = startSessionRecord(db, videoId, startedAtMs + 60_000);
|
const fallbackOnly = startSessionRecord(db, videoId, startedAtMs + 60_000);
|
||||||
|
// Still running, so its words stay out of the all-time total.
|
||||||
|
const stillActive = startSessionRecord(db, videoId, startedAtMs + 120_000);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -1543,6 +1547,14 @@ test('word-count read models use filtered persisted occurrences with raw fallbac
|
|||||||
occurrenceCount: 2,
|
occurrenceCount: 2,
|
||||||
startedAtMs,
|
startedAtMs,
|
||||||
});
|
});
|
||||||
|
insertFilteredWordOccurrence(db, {
|
||||||
|
sessionId: stillActive.sessionId,
|
||||||
|
videoId,
|
||||||
|
lineIndex: 3,
|
||||||
|
occurrenceCount: 4,
|
||||||
|
startedAtMs: startedAtMs + 120_000,
|
||||||
|
headword: '犬',
|
||||||
|
});
|
||||||
insertFilteredWordOccurrence(db, {
|
insertFilteredWordOccurrence(db, {
|
||||||
sessionId: withOccurrences.sessionId,
|
sessionId: withOccurrences.sessionId,
|
||||||
videoId,
|
videoId,
|
||||||
@@ -4738,7 +4750,7 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
|||||||
|
|
||||||
const trends = getTrendsDashboard(db, 'all', 'day');
|
const trends = getTrendsDashboard(db, 'all', 'day');
|
||||||
assert.deepEqual(trends.activity.watchTime, []);
|
assert.deepEqual(trends.activity.watchTime, []);
|
||||||
assert.deepEqual(trends.activity.sessions, []);
|
assert.deepEqual(trends.activity.cards, []);
|
||||||
|
|
||||||
const dailyRollups = getDailyRollups(db, 30);
|
const dailyRollups = getDailyRollups(db, 30);
|
||||||
const monthlyRollups = getMonthlyRollups(db, 30);
|
const monthlyRollups = getMonthlyRollups(db, 30);
|
||||||
@@ -5060,7 +5072,7 @@ test('getTrendsDashboard rollup word metrics keep persisted totals over partial
|
|||||||
total_tokens_seen, total_cards
|
total_tokens_seen, total_cards
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(Math.floor(row.start / 86_400_000), videoId, 1, 10, 1, row.rawWords, 0);
|
).run(getLocalEpochDay(db, row.start), videoId, 1, 10, 1, row.rawWords, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||||
@@ -5072,10 +5084,8 @@ test('getTrendsDashboard rollup word metrics keep persisted totals over partial
|
|||||||
dashboard.progress.words.map((point) => point.value),
|
dashboard.progress.words.map((point) => point.value),
|
||||||
[10, 30],
|
[10, 30],
|
||||||
);
|
);
|
||||||
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, 200);
|
|
||||||
assert.equal(dashboard.librarySummary[0]?.words, 30);
|
assert.equal(dashboard.librarySummary[0]?.words, 30);
|
||||||
assert.equal(dashboard.librarySummary[0]?.lookupsPerHundred, 33.3);
|
assert.equal(dashboard.librarySummary[0]?.lookupsPerHundred, 33.3);
|
||||||
assert.equal(dashboard.animeCumulative.words.at(-1)?.value, 30);
|
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ import {
|
|||||||
getLexicalDailyRollups,
|
getLexicalDailyRollups,
|
||||||
rebuildLexicalDailyRollups,
|
rebuildLexicalDailyRollups,
|
||||||
} from './lexical-rollups';
|
} from './lexical-rollups';
|
||||||
import { getTrendsDashboard } from './query-trends';
|
|
||||||
import {
|
import {
|
||||||
getVocabularyChartData,
|
getVocabularyChartData,
|
||||||
getVocabularySummary,
|
getVocabularySummary,
|
||||||
replaceStatsExcludedWords,
|
replaceStatsExcludedWords,
|
||||||
} from './query-lexical';
|
} from './query-lexical';
|
||||||
|
import { cleanupVocabularyStats } from './query-maintenance';
|
||||||
import { Database } from './sqlite';
|
import { Database } from './sqlite';
|
||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
|
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
|
||||||
|
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
|
||||||
import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
|
import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
|
||||||
|
|
||||||
function makeDbPath(): string {
|
function makeDbPath(): string {
|
||||||
@@ -260,7 +261,7 @@ test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
|
|||||||
assert.equal(areLexicalDailyRollupsReady(db), true);
|
assert.equal(areLexicalDailyRollupsReady(db), true);
|
||||||
assert.equal(getVocabularySummary(db, null).uniqueWords, 1);
|
assert.equal(getVocabularySummary(db, null).uniqueWords, 1);
|
||||||
assert.equal(getVocabularySummary(db, new Set(['猫'])).knownWordCount, 1);
|
assert.equal(getVocabularySummary(db, new Set(['猫'])).knownWordCount, 1);
|
||||||
assert.equal(getVocabularyChartData(db).topWords[0]?.headword, '猫');
|
assert.equal(getVocabularyChartData(db).newWordsTimeline[0]?.wordCount, 1);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||||
@@ -384,7 +385,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
|
|||||||
state_value INTEGER NOT NULL
|
state_value INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
INSERT INTO imm_rollup_state(state_key, state_value)
|
INSERT INTO imm_rollup_state(state_key, state_value)
|
||||||
VALUES ('lexical_daily_rollups_version', 3);
|
VALUES ('lexical_daily_rollups_version', 4);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
assert.equal(areLexicalDailyRollupsReady(db), true);
|
assert.equal(areLexicalDailyRollupsReady(db), true);
|
||||||
@@ -394,6 +395,185 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function insertWord(
|
||||||
|
db: DatabaseSync,
|
||||||
|
word: {
|
||||||
|
headword: string;
|
||||||
|
word: string;
|
||||||
|
reading: string;
|
||||||
|
partOfSpeech: string;
|
||||||
|
pos1: string;
|
||||||
|
pos2: string;
|
||||||
|
},
|
||||||
|
firstSeen: number,
|
||||||
|
): number {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO imm_words(
|
||||||
|
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, '', ?, ?, 1)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
word.headword,
|
||||||
|
word.word,
|
||||||
|
word.reading,
|
||||||
|
word.partOfSpeech,
|
||||||
|
word.pos1,
|
||||||
|
word.pos2,
|
||||||
|
firstSeen,
|
||||||
|
firstSeen,
|
||||||
|
);
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPersistedVisibilityMatchesPredicate(db: DatabaseSync): void {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT headword, word, reading, part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||||
|
frequency_rank AS frequencyRank, vocabulary_visible AS vocabularyVisible
|
||||||
|
FROM imm_words`,
|
||||||
|
)
|
||||||
|
.all() as Array<VocabularyVisibilityRow & { vocabularyVisible: number }>;
|
||||||
|
assert.ok(rows.length > 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((row) => [row.headword, row.vocabularyVisible === 1]),
|
||||||
|
rows.map((row) => [row.headword, isVocabularyStatsRowVisible(row)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cleanupVocabularyStats re-derives visibility for a repaired word', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const epochDay = 19_500;
|
||||||
|
const firstSeen = epochDay * 86_400 + 43_200;
|
||||||
|
const legacyWord = {
|
||||||
|
headword: '旧',
|
||||||
|
word: '旧',
|
||||||
|
reading: '',
|
||||||
|
partOfSpeech: 'particle',
|
||||||
|
pos1: '助詞',
|
||||||
|
pos2: '係助詞',
|
||||||
|
};
|
||||||
|
const legacyId = insertWord(db, legacyWord, firstSeen);
|
||||||
|
rebuildLexicalDailyRollups(db);
|
||||||
|
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
|
||||||
|
|
||||||
|
await cleanupVocabularyStats(db, {
|
||||||
|
resolveLegacyPos: async (row) =>
|
||||||
|
row.id === legacyId
|
||||||
|
? {
|
||||||
|
headword: '旧',
|
||||||
|
reading: 'きゅう',
|
||||||
|
partOfSpeech: 'noun',
|
||||||
|
pos1: '名詞',
|
||||||
|
pos2: '一般',
|
||||||
|
pos3: '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertPersistedVisibilityMatchesPredicate(db);
|
||||||
|
assert.equal(getVocabularySummary(db, null).uniqueWords, 1);
|
||||||
|
assert.equal(getLexicalDailyRollups(db)[0]?.wordCount, 1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanupVocabularyStats re-derives visibility for a merged duplicate', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const firstSeen = 19_500 * 86_400 + 43_200;
|
||||||
|
// The merge fills these blank POS fields from the repair, which hides the word.
|
||||||
|
insertWord(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
headword: '知る',
|
||||||
|
word: '知っている',
|
||||||
|
reading: 'しっている',
|
||||||
|
partOfSpeech: 'noun',
|
||||||
|
pos1: '',
|
||||||
|
pos2: '',
|
||||||
|
},
|
||||||
|
firstSeen,
|
||||||
|
);
|
||||||
|
const legacyId = insertWord(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
headword: '知っている',
|
||||||
|
word: '知っている',
|
||||||
|
reading: '',
|
||||||
|
partOfSpeech: 'verb',
|
||||||
|
pos1: '動詞',
|
||||||
|
pos2: '自立',
|
||||||
|
},
|
||||||
|
firstSeen,
|
||||||
|
);
|
||||||
|
rebuildLexicalDailyRollups(db);
|
||||||
|
assert.equal(getVocabularySummary(db, null).uniqueWords, 2);
|
||||||
|
|
||||||
|
await cleanupVocabularyStats(db, {
|
||||||
|
resolveLegacyPos: async (row) =>
|
||||||
|
row.id === legacyId
|
||||||
|
? {
|
||||||
|
headword: '知る',
|
||||||
|
reading: 'しっている',
|
||||||
|
partOfSpeech: 'particle',
|
||||||
|
pos1: '助詞',
|
||||||
|
pos2: '係助詞',
|
||||||
|
pos3: '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertPersistedVisibilityMatchesPredicate(db);
|
||||||
|
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
|
||||||
|
assert.deepEqual(getLexicalDailyRollups(db), []);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a queued word write persists visibility from the stored row', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const firstSeen = 19_500 * 86_400 + 43_200;
|
||||||
|
executeQueuedWrite(
|
||||||
|
{
|
||||||
|
kind: 'word',
|
||||||
|
headword: 'は',
|
||||||
|
word: 'は',
|
||||||
|
reading: 'は',
|
||||||
|
partOfSpeech: 'particle',
|
||||||
|
pos1: '助詞',
|
||||||
|
pos2: '係助詞',
|
||||||
|
pos3: '',
|
||||||
|
firstSeen,
|
||||||
|
lastSeen: firstSeen,
|
||||||
|
frequencyRank: null,
|
||||||
|
},
|
||||||
|
createTrackerPreparedStatements(db),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertPersistedVisibilityMatchesPredicate(db);
|
||||||
|
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('imm_words persists vocabulary visibility for rollup maintenance', () => {
|
test('imm_words persists vocabulary visibility for rollup maintenance', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -412,7 +592,7 @@ test('imm_words persists vocabulary visibility for rollup maintenance', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('vocabulary charts use complete top-word and lexical rollup data', () => {
|
test('vocabulary charts count every word in new-word history', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
@@ -430,8 +610,6 @@ test('vocabulary charts use complete top-word and lexical rollup data', () => {
|
|||||||
|
|
||||||
const charts = getVocabularyChartData(db);
|
const charts = getVocabularyChartData(db);
|
||||||
|
|
||||||
assert.equal(charts.topWords[0]?.headword, '語500');
|
|
||||||
assert.equal(charts.topWords[0]?.frequency, 10_000);
|
|
||||||
assert.equal(charts.newWordsTimeline[0]?.wordCount, 501);
|
assert.equal(charts.newWordsTimeline[0]?.wordCount, 501);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
@@ -439,41 +617,6 @@ test('vocabulary charts use complete top-word and lexical rollup data', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('vocabulary charts find full top-word sets beyond excluded and name rows', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
|
||||||
const insertWord = db.prepare(
|
|
||||||
`INSERT INTO imm_words(headword, word, reading, pos2, first_seen, last_seen, frequency)
|
|
||||||
VALUES (?, ?, '', ?, 1700000000, 1700000000, ?)`,
|
|
||||||
);
|
|
||||||
const exclusions = [];
|
|
||||||
for (let index = 0; index < 100; index += 1) {
|
|
||||||
const headword = `語${index}`;
|
|
||||||
insertWord.run(
|
|
||||||
headword,
|
|
||||||
headword,
|
|
||||||
index < 80 && index >= 60 ? '固有名詞' : '一般',
|
|
||||||
100 - index,
|
|
||||||
);
|
|
||||||
if (index < 60) exclusions.push({ headword, word: headword, reading: '' });
|
|
||||||
}
|
|
||||||
replaceStatsExcludedWords(db, exclusions);
|
|
||||||
|
|
||||||
const charts = getVocabularyChartData(db);
|
|
||||||
|
|
||||||
assert.equal(charts.topWords.length, 12);
|
|
||||||
assert.equal(charts.topWords[0]?.headword, '語60');
|
|
||||||
assert.equal(charts.topWordsWithoutNames.length, 12);
|
|
||||||
assert.equal(charts.topWordsWithoutNames[0]?.headword, '語80');
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('vocabulary charts handle exclusion lists above one SQLite variable batch', () => {
|
test('vocabulary charts handle exclusion lists above one SQLite variable batch', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -493,7 +636,6 @@ test('vocabulary charts handle exclusion lists above one SQLite variable batch',
|
|||||||
|
|
||||||
const charts = getVocabularyChartData(db);
|
const charts = getVocabularyChartData(db);
|
||||||
|
|
||||||
assert.deepEqual(charts.topWords, []);
|
|
||||||
assert.deepEqual(charts.newWordsTimeline, []);
|
assert.deepEqual(charts.newWordsTimeline, []);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
@@ -516,24 +658,3 @@ test('lexical rollup rebuild preserves the original error when rollback also fai
|
|||||||
|
|
||||||
assert.throws(() => rebuildLexicalDailyRollups(db), originalError);
|
assert.throws(() => rebuildLexicalDailyRollups(db), originalError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('trends read historical new-word buckets from lexical rollups', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
|
|
||||||
VALUES ('海', '海', 'うみ', 1700000000, 1700000000, 1)`,
|
|
||||||
).run();
|
|
||||||
db.prepare(`UPDATE imm_lexical_daily_rollups SET word_count = 9`).run();
|
|
||||||
|
|
||||||
const dashboard = getTrendsDashboard(db, 'all', 'day', false);
|
|
||||||
|
|
||||||
assert.equal(dashboard.progress.newWords[0]?.value, 9);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -19,8 +19,9 @@ const LOCAL_EPOCH_DAY_SQL = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// v3 re-derives visibility for rows a re-seen word forced visible and repairs
|
// v3 re-derives visibility for rows a re-seen word forced visible and repairs
|
||||||
// last_seen values left behind by session deletes.
|
// last_seen values left behind by session deletes. v4 re-derives visibility for
|
||||||
const LEXICAL_DAILY_ROLLUP_VERSION = '3';
|
// rows vocabulary cleanup repaired without updating it.
|
||||||
|
const LEXICAL_DAILY_ROLLUP_VERSION = '4';
|
||||||
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
|
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
|
||||||
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
|
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TrendFrequencyBand } from '../../../types/stats-wire';
|
||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import type {
|
import type {
|
||||||
KanjiAnimeAppearanceRow,
|
KanjiAnimeAppearanceRow,
|
||||||
@@ -16,20 +17,47 @@ import type {
|
|||||||
WordDetailRow,
|
WordDetailRow,
|
||||||
WordOccurrenceRow,
|
WordOccurrenceRow,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { fromDbTimestamp, toDbTimestamp } from './query-shared';
|
import { forEachIdChunk, fromDbTimestamp, makePlaceholders, toDbTimestamp } from './query-shared';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
import { narrowFullwidthKana, widenHalfwidthKana } from '../../utils/halfwidth-kana';
|
import { narrowFullwidthKana, widenHalfwidthKana } from '../../utils/halfwidth-kana';
|
||||||
import {
|
import { areLexicalDailyRollupsReady, localEpochDaySql } from './lexical-rollups';
|
||||||
areLexicalDailyRollupsReady,
|
|
||||||
getLexicalDailyRollups,
|
|
||||||
localEpochDaySql,
|
|
||||||
} from './lexical-rollups';
|
|
||||||
import { isVocabularyStatsRowVisible } from './vocabulary-visibility';
|
import { isVocabularyStatsRowVisible } from './vocabulary-visibility';
|
||||||
|
|
||||||
|
// Merged tokens join MeCab tags, so a name can be stored as 固有名詞|一般 or 固有名詞|接尾.
|
||||||
|
const PROPER_NOUN_TAG = '固有名詞';
|
||||||
|
const NOT_A_NAME_SQL = `COALESCE(pos2, '') NOT LIKE '%${PROPER_NOUN_TAG}%'`;
|
||||||
|
|
||||||
|
/** The dictionary word an `imm_words` row belongs to. Legacy rows can lack a headword or form. */
|
||||||
|
export const DICTIONARY_WORD_KEY_SQL = `COALESCE(NULLIF(TRIM(headword), ''), NULLIF(TRIM(word), ''), TRIM(reading))`;
|
||||||
|
|
||||||
|
/** True for lines from a stream: only local videos have a path to cut clips from. */
|
||||||
|
export const NO_LOCAL_FILE_SQL = `TRIM(COALESCE(v.source_path, '')) = ''`;
|
||||||
|
|
||||||
|
// Sorts lines that can be mined ahead of those that can't, because newest-first alone
|
||||||
|
// lets a run of recent streams bury every line with a usable clip.
|
||||||
|
// Mirrors getStatsMineCardUnavailableReason in the stats client.
|
||||||
|
const MINEABLE_LINES_FIRST_SQL = `
|
||||||
|
CASE
|
||||||
|
WHEN NOT (${NO_LOCAL_FILE_SQL})
|
||||||
|
AND l.segment_start_ms IS NOT NULL
|
||||||
|
AND l.segment_end_ms > l.segment_start_ms
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END`;
|
||||||
|
|
||||||
|
export function isProperNounPos2(pos2: string | null | undefined): boolean {
|
||||||
|
return pos2?.includes(PROPER_NOUN_TAG) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewWordFirstSeenRow {
|
||||||
|
firstSeenDay: number;
|
||||||
|
firstSeenDayWithoutNames: number | null;
|
||||||
|
band: TrendFrequencyBand;
|
||||||
|
wordCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
|
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
|
||||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
||||||
const VOCABULARY_CHART_LIMIT = 12;
|
|
||||||
const VOCABULARY_CHART_PAGE_SIZE = 100;
|
|
||||||
const EXCLUSION_ALIAS_BATCH_SIZE = 300;
|
const EXCLUSION_ALIAS_BATCH_SIZE = 300;
|
||||||
const VOCABULARY_SUMMARY_SCAN_BATCH_SIZE = 5_000;
|
const VOCABULARY_SUMMARY_SCAN_BATCH_SIZE = 5_000;
|
||||||
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
||||||
@@ -38,8 +66,6 @@ const KANJI_PATTERN = /\p{Script=Han}/gu;
|
|||||||
|
|
||||||
export interface VocabularyChartData {
|
export interface VocabularyChartData {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
topWords: Array<{ wordId: number; headword: string; frequency: number }>;
|
|
||||||
topWordsWithoutNames: Array<{ wordId: number; headword: string; frequency: number }>;
|
|
||||||
newWordsTimeline: Array<{ epochDay: number; wordCount: number }>;
|
newWordsTimeline: Array<{ epochDay: number; wordCount: number }>;
|
||||||
newWordsTimelineWithoutNames: Array<{ epochDay: number; wordCount: number }>;
|
newWordsTimelineWithoutNames: Array<{ epochDay: number; wordCount: number }>;
|
||||||
}
|
}
|
||||||
@@ -106,150 +132,192 @@ export function getVocabularyStats(
|
|||||||
const whereClause = hasExclude
|
const whereClause = hasExclude
|
||||||
? `WHERE (part_of_speech IS NULL OR part_of_speech NOT IN (${placeholders}))`
|
? `WHERE (part_of_speech IS NULL OR part_of_speech NOT IN (${placeholders}))`
|
||||||
: '';
|
: '';
|
||||||
// The page is selected before the join so `animeCount` is only computed for the
|
// Rows are paged and filtered before `animeCount` is computed, and only the
|
||||||
// rows being returned. Aggregating first made every request walk each word's
|
// rows being returned are counted. The count walks each word's entire
|
||||||
// entire occurrence history — seconds of blocked event loop on a large library,
|
// occurrence history, and the most frequent words hold most occurrences, so
|
||||||
// because only the ordering, not the aggregate, decides which rows survive.
|
// counting the oversampled page or aggregating first cost seconds of blocked
|
||||||
|
// event loop on a large library.
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
WITH page AS (
|
SELECT id AS wordId, headword, word, reading,
|
||||||
SELECT id, headword, word, reading, part_of_speech, pos1, pos2, pos3,
|
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||||
frequency, frequency_rank, first_seen, last_seen
|
frequency, frequency_rank AS frequencyRank,
|
||||||
FROM imm_words
|
first_seen AS firstSeen, last_seen AS lastSeen
|
||||||
${whereClause}
|
FROM imm_words
|
||||||
ORDER BY frequency DESC, id
|
${whereClause}
|
||||||
LIMIT ? OFFSET ?
|
ORDER BY frequency DESC, id
|
||||||
)
|
LIMIT ? OFFSET ?
|
||||||
SELECT p.id AS wordId, p.headword, p.word, p.reading,
|
|
||||||
p.part_of_speech AS partOfSpeech, p.pos1, p.pos2, p.pos3,
|
|
||||||
p.frequency, p.frequency_rank AS frequencyRank,
|
|
||||||
p.first_seen AS firstSeen, p.last_seen AS lastSeen,
|
|
||||||
COUNT(DISTINCT sl.anime_id) AS animeCount
|
|
||||||
FROM page p
|
|
||||||
LEFT JOIN imm_word_line_occurrences o ON o.word_id = p.id
|
|
||||||
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id AND sl.anime_id IS NOT NULL
|
|
||||||
GROUP BY p.id
|
|
||||||
ORDER BY p.frequency DESC, p.id
|
|
||||||
`);
|
`);
|
||||||
const visibleRows: VocabularyStatsRow[] = [];
|
const visibleRows: Array<Omit<VocabularyStatsRow, 'animeCount'>> = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
while (visibleRows.length < limit) {
|
while (visibleRows.length < limit) {
|
||||||
const params = hasExclude ? [...excludePos, pageSize, offset] : [pageSize, offset];
|
const params = hasExclude ? [...excludePos, pageSize, offset] : [pageSize, offset];
|
||||||
const page = stmt.all(...params) as VocabularyStatsRow[];
|
const page = stmt.all(...params) as Array<Omit<VocabularyStatsRow, 'animeCount'>>;
|
||||||
if (page.length === 0) break;
|
if (page.length === 0) break;
|
||||||
visibleRows.push(...page.filter(isVocabularyStatsRowVisible));
|
visibleRows.push(...page.filter(isVocabularyStatsRowVisible));
|
||||||
offset += page.length;
|
offset += page.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return visibleRows.slice(0, limit);
|
const rows = visibleRows.slice(0, limit);
|
||||||
|
const animeCounts = getWordAnimeCounts(
|
||||||
|
db,
|
||||||
|
rows.map((row) => row.wordId),
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({ ...row, animeCount: animeCounts.get(row.wordId) ?? 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of distinct anime each word appears in, keyed by word id. */
|
||||||
|
function getWordAnimeCounts(db: DatabaseSync, wordIds: number[]): Map<number, number> {
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
forEachIdChunk(wordIds, (chunk) => {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT o.word_id AS wordId, COUNT(DISTINCT sl.anime_id) AS animeCount
|
||||||
|
FROM imm_word_line_occurrences o
|
||||||
|
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||||
|
WHERE o.word_id IN (${makePlaceholders(chunk)}) AND sl.anime_id IS NOT NULL
|
||||||
|
GROUP BY o.word_id
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(...chunk) as Array<{ wordId: number; animeCount: number }>;
|
||||||
|
for (const row of rows) counts.set(row.wordId, row.animeCount);
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chart data is intentionally independent of the paginated vocabulary tables.
|
* New-word history is a SQL aggregate over first-seen dates rather than loading
|
||||||
* Top words use the frequency index; new-word history reads permanent daily
|
* every vocabulary row into the dashboard.
|
||||||
* lexical rollups rather than loading every vocabulary row into the dashboard.
|
|
||||||
*/
|
*/
|
||||||
export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData {
|
export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData {
|
||||||
const ready = areLexicalDailyRollupsReady(db);
|
const ready = areLexicalDailyRollupsReady(db);
|
||||||
const excludedAliases = new Set(
|
const firstSeenRows = ready ? getNewWordFirstSeenRows(db) : [];
|
||||||
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
const timeline = new Map<number, number>();
|
||||||
);
|
const timelineWithoutNames = new Map<number, number>();
|
||||||
const isExcluded = (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>): boolean =>
|
for (const row of firstSeenRows) {
|
||||||
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias));
|
timeline.set(row.firstSeenDay, (timeline.get(row.firstSeenDay) ?? 0) + row.wordCount);
|
||||||
const topWords = getTopVocabularyChartWords(db, isExcluded);
|
if (row.firstSeenDayWithoutNames !== null) {
|
||||||
const rollups = ready ? getLexicalDailyRollups(db) : [];
|
timelineWithoutNames.set(
|
||||||
const timeline = new Map(rollups.map((row) => [row.epochDay, { ...row }]));
|
row.firstSeenDayWithoutNames,
|
||||||
if (excludedAliases.size > 0 && ready) {
|
(timelineWithoutNames.get(row.firstSeenDayWithoutNames) ?? 0) + row.wordCount,
|
||||||
const aliases = [...excludedAliases];
|
);
|
||||||
const excludedRows = new Map<
|
|
||||||
number,
|
|
||||||
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
|
|
||||||
wordId: number;
|
|
||||||
epochDay: number;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
for (let offset = 0; offset < aliases.length; offset += EXCLUSION_ALIAS_BATCH_SIZE) {
|
|
||||||
const batch = aliases.slice(offset, offset + EXCLUSION_ALIAS_BATCH_SIZE);
|
|
||||||
const placeholders = batch.map(() => '?').join(', ');
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT id AS wordId, headword, word, reading, pos2,
|
|
||||||
${localEpochDaySql('first_seen')} AS epochDay
|
|
||||||
FROM imm_words
|
|
||||||
WHERE vocabulary_visible = 1
|
|
||||||
AND (headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders}))
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(...batch, ...batch, ...batch) as Array<
|
|
||||||
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
|
|
||||||
wordId: number;
|
|
||||||
epochDay: number;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
for (const row of rows) excludedRows.set(row.wordId, row);
|
|
||||||
}
|
|
||||||
for (const word of excludedRows.values()) {
|
|
||||||
if (!isExcluded(word)) continue;
|
|
||||||
const rollup = timeline.get(word.epochDay);
|
|
||||||
if (!rollup) continue;
|
|
||||||
rollup.wordCount -= 1;
|
|
||||||
if (word.pos2 !== '固有名詞') rollup.wordCountWithoutNames -= 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
topWords: topWords.all.map((word) => ({
|
newWordsTimeline: toNewWordTimeline(timeline),
|
||||||
wordId: word.wordId,
|
newWordsTimelineWithoutNames: toNewWordTimeline(timelineWithoutNames),
|
||||||
headword: vocabularyDisplayHeadword(word),
|
|
||||||
frequency: word.frequency,
|
|
||||||
})),
|
|
||||||
topWordsWithoutNames: topWords.withoutNames.map((word) => ({
|
|
||||||
wordId: word.wordId,
|
|
||||||
headword: vocabularyDisplayHeadword(word),
|
|
||||||
frequency: word.frequency,
|
|
||||||
})),
|
|
||||||
newWordsTimeline: [...timeline.values()]
|
|
||||||
.filter((row) => row.wordCount > 0)
|
|
||||||
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCount })),
|
|
||||||
newWordsTimelineWithoutNames: [...timeline.values()]
|
|
||||||
.filter((row) => row.wordCountWithoutNames > 0)
|
|
||||||
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCountWithoutNames })),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopVocabularyChartWords(
|
function toNewWordTimeline(byDay: Map<number, number>): VocabularyChartData['newWordsTimeline'] {
|
||||||
db: DatabaseSync,
|
return [...byDay.entries()]
|
||||||
isExcluded: (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>) => boolean,
|
.sort(([left], [right]) => left - right)
|
||||||
): { all: VocabularyStatsRow[]; withoutNames: VocabularyStatsRow[] } {
|
.map(([epochDay, wordCount]) => ({ epochDay, wordCount }));
|
||||||
const stmt = db.prepare(`
|
}
|
||||||
SELECT id AS wordId, headword, word, reading,
|
|
||||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
|
||||||
frequency, frequency_rank AS frequencyRank,
|
|
||||||
first_seen AS firstSeen, last_seen AS lastSeen,
|
|
||||||
0 AS animeCount
|
|
||||||
FROM imm_words
|
|
||||||
ORDER BY frequency DESC, id
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const all: VocabularyStatsRow[] = [];
|
|
||||||
const withoutNames: VocabularyStatsRow[] = [];
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
while (all.length < VOCABULARY_CHART_LIMIT || withoutNames.length < VOCABULARY_CHART_LIMIT) {
|
/** Word rows matching the user's stats exclusions, using the same aliases as the summary. */
|
||||||
const page = stmt.all(VOCABULARY_CHART_PAGE_SIZE, offset) as VocabularyStatsRow[];
|
export function getExcludedWordIds(db: DatabaseSync): number[] {
|
||||||
if (page.length === 0) break;
|
const excludedAliases = new Set(
|
||||||
for (const word of page) {
|
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
||||||
if (!isVocabularyStatsRowVisible(word) || isExcluded(word)) continue;
|
);
|
||||||
if (all.length < VOCABULARY_CHART_LIMIT) all.push(word);
|
const aliases = [...excludedAliases];
|
||||||
if (word.pos2 !== '固有名詞' && withoutNames.length < VOCABULARY_CHART_LIMIT) {
|
const ids = new Set<number>();
|
||||||
withoutNames.push(word);
|
for (let offset = 0; offset < aliases.length; offset += EXCLUSION_ALIAS_BATCH_SIZE) {
|
||||||
|
const batch = aliases.slice(offset, offset + EXCLUSION_ALIAS_BATCH_SIZE);
|
||||||
|
const placeholders = batch.map(() => '?').join(', ');
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id AS wordId, headword, word, reading
|
||||||
|
FROM imm_words
|
||||||
|
WHERE vocabulary_visible = 1
|
||||||
|
AND (headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders}))
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(...batch, ...batch, ...batch) as Array<
|
||||||
|
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'> & { wordId: number }
|
||||||
|
>;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (excludedVocabularyAliases(row).some((alias) => excludedAliases.has(alias))) {
|
||||||
|
ids.add(row.wordId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
offset += page.length;
|
|
||||||
}
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
return { all, withoutNames };
|
// The aggregate below scans every word (about 200ms on 68k rows), and the Trends tab
|
||||||
|
// runs it on each control change. Results stay valid until something writes:
|
||||||
|
// total_changes covers this connection, data_version covers other connections.
|
||||||
|
const newWordFirstSeenCache = new WeakMap<
|
||||||
|
DatabaseSync,
|
||||||
|
{ version: string; rows: NewWordFirstSeenRow[] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
export function databaseWriteVersion(db: DatabaseSync): string {
|
||||||
|
const { changes } = db.prepare('SELECT total_changes() AS changes').get() as { changes: number };
|
||||||
|
const { data_version: dataVersion } = db.prepare('PRAGMA data_version').get() as {
|
||||||
|
data_version: number;
|
||||||
|
};
|
||||||
|
return `${changes}:${dataVersion}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When each dictionary word was first seen, folded across its surface forms:
|
||||||
|
* `imm_words` stores 言う and 言った as separate rows, so counting rows would count
|
||||||
|
* conjugations as new words. Rows group identical (day, day without names, band)
|
||||||
|
* results so the caller gets a small aggregate rather than one row per word.
|
||||||
|
* `firstSeenDayWithoutNames` and `band` ignore proper-noun forms, and the day is
|
||||||
|
* null when every form of the word is a proper noun.
|
||||||
|
*/
|
||||||
|
export function getNewWordFirstSeenRows(db: DatabaseSync): NewWordFirstSeenRow[] {
|
||||||
|
const version = databaseWriteVersion(db);
|
||||||
|
const cached = newWordFirstSeenCache.get(db);
|
||||||
|
if (cached?.version === version) return cached.rows;
|
||||||
|
|
||||||
|
const excludedIds = getExcludedWordIds(db);
|
||||||
|
const exclusion =
|
||||||
|
excludedIds.length > 0 ? `AND id NOT IN (${makePlaceholders(excludedIds)})` : '';
|
||||||
|
const seconds = `CASE
|
||||||
|
WHEN ABS(CAST(first_seen AS REAL)) >= 10000000000 THEN CAST(first_seen AS REAL) / 1000
|
||||||
|
ELSE CAST(first_seen AS REAL)
|
||||||
|
END`;
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
WITH dictionary_words AS (
|
||||||
|
SELECT
|
||||||
|
MIN(${seconds}) AS firstSeen,
|
||||||
|
MIN(CASE WHEN ${NOT_A_NAME_SQL} THEN ${seconds} END) AS firstSeenWithoutNames,
|
||||||
|
MIN(CASE WHEN ${NOT_A_NAME_SQL} THEN frequency_rank END) AS rank
|
||||||
|
FROM imm_words
|
||||||
|
WHERE vocabulary_visible = 1 AND first_seen IS NOT NULL ${exclusion}
|
||||||
|
GROUP BY ${DICTIONARY_WORD_KEY_SQL}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
${localEpochDaySql('firstSeen')} AS firstSeenDay,
|
||||||
|
CASE
|
||||||
|
WHEN firstSeenWithoutNames IS NULL THEN NULL
|
||||||
|
ELSE ${localEpochDaySql('firstSeenWithoutNames')}
|
||||||
|
END AS firstSeenDayWithoutNames,
|
||||||
|
CASE
|
||||||
|
WHEN rank IS NULL THEN 'unranked'
|
||||||
|
WHEN rank <= 1000 THEN 'top1k'
|
||||||
|
WHEN rank <= 5000 THEN 'top5k'
|
||||||
|
WHEN rank <= 10000 THEN 'top10k'
|
||||||
|
WHEN rank <= 20000 THEN 'top20k'
|
||||||
|
ELSE 'beyond20k'
|
||||||
|
END AS band,
|
||||||
|
COUNT(*) AS wordCount
|
||||||
|
FROM dictionary_words
|
||||||
|
GROUP BY 1, 2, 3
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(...excludedIds) as unknown as NewWordFirstSeenRow[];
|
||||||
|
newWordFirstSeenCache.set(db, { version, rows });
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function excludedVocabularyAliases(
|
function excludedVocabularyAliases(
|
||||||
@@ -276,8 +344,12 @@ export function getVocabularySummary(
|
|||||||
nowMs: number = Date.now(),
|
nowMs: number = Date.now(),
|
||||||
scanBatchSize: number = VOCABULARY_SUMMARY_SCAN_BATCH_SIZE,
|
scanBatchSize: number = VOCABULARY_SUMMARY_SCAN_BATCH_SIZE,
|
||||||
): VocabularyStatsSummary {
|
): VocabularyStatsSummary {
|
||||||
// Visibility and exclusion rules live in JS, so rows are scanned in id-keyed
|
// Exclusion and known-word rules live in JS, so rows are scanned in id-keyed
|
||||||
// batches to keep memory bounded on large vocabularies.
|
// batches to keep memory bounded on large vocabularies. Once the lexical
|
||||||
|
// rollups are ready, `vocabulary_visible` holds the persisted result of
|
||||||
|
// isVocabularyStatsRowVisible (the same column the new-word charts count), so
|
||||||
|
// the scan skips re-running the subtitle filter rules for every word.
|
||||||
|
const persistedVisibility = areLexicalDailyRollupsReady(db);
|
||||||
const scanStmt = db.prepare(`
|
const scanStmt = db.prepare(`
|
||||||
SELECT id AS wordId, headword, word, reading,
|
SELECT id AS wordId, headword, word, reading,
|
||||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||||
@@ -285,10 +357,11 @@ export function getVocabularySummary(
|
|||||||
first_seen AS firstSeen, last_seen AS lastSeen,
|
first_seen AS firstSeen, last_seen AS lastSeen,
|
||||||
0 AS animeCount
|
0 AS animeCount
|
||||||
FROM imm_words
|
FROM imm_words
|
||||||
WHERE id > ?
|
WHERE id > ?${persistedVisibility ? ' AND vocabulary_visible = 1' : ''}
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`);
|
`);
|
||||||
|
const isVisible = persistedVisibility ? () => true : isVocabularyStatsRowVisible;
|
||||||
const excludedAliases = new Set(
|
const excludedAliases = new Set(
|
||||||
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
||||||
);
|
);
|
||||||
@@ -303,6 +376,12 @@ export function getVocabularySummary(
|
|||||||
knownWordCount: knownWords ? 0 : null,
|
knownWordCount: knownWords ? 0 : null,
|
||||||
knownWordCountWithoutNames: knownWords ? 0 : null,
|
knownWordCountWithoutNames: knownWords ? 0 : null,
|
||||||
};
|
};
|
||||||
|
// Keyed by dictionary form, so a word's conjugations count once and it is new
|
||||||
|
// this week only if no form of it was seen earlier.
|
||||||
|
const dictionaryWords = new Map<
|
||||||
|
string,
|
||||||
|
{ firstSeenSec: number; firstSeenSecWithoutNames: number | null; known: boolean }
|
||||||
|
>();
|
||||||
|
|
||||||
let lastId = Number.MIN_SAFE_INTEGER;
|
let lastId = Number.MIN_SAFE_INTEGER;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -311,28 +390,45 @@ export function getVocabularySummary(
|
|||||||
lastId = words[words.length - 1]!.wordId;
|
lastId = words[words.length - 1]!.wordId;
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
if (
|
if (
|
||||||
!isVocabularyStatsRowVisible(word) ||
|
!isVisible(word) ||
|
||||||
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias))
|
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias))
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const isName = word.pos2 === '固有名詞';
|
const firstSeenSec = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0);
|
||||||
const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec;
|
const key = vocabularyDisplayHeadword(word);
|
||||||
const isKnown = knownWords?.has(vocabularyDisplayHeadword(word)) ?? false;
|
const entry = dictionaryWords.get(key) ?? {
|
||||||
summary.uniqueWords += 1;
|
firstSeenSec,
|
||||||
if (!isName) summary.uniqueWordsWithoutNames += 1;
|
firstSeenSecWithoutNames: null,
|
||||||
if (isNewThisWeek) {
|
known: false,
|
||||||
summary.newThisWeek += 1;
|
};
|
||||||
if (!isName) summary.newThisWeekWithoutNames += 1;
|
entry.known ||= (knownWords?.has(key) ?? false) || (knownWords?.has(word.word) ?? false);
|
||||||
}
|
entry.firstSeenSec = Math.min(entry.firstSeenSec, firstSeenSec);
|
||||||
if (isKnown) {
|
if (!isProperNounPos2(word.pos2)) {
|
||||||
summary.knownWordCount! += 1;
|
entry.firstSeenSecWithoutNames = Math.min(
|
||||||
if (!isName) summary.knownWordCountWithoutNames! += 1;
|
entry.firstSeenSecWithoutNames ?? firstSeenSec,
|
||||||
|
firstSeenSec,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
dictionaryWords.set(key, entry);
|
||||||
}
|
}
|
||||||
if (words.length < scanBatchSize) break;
|
if (words.length < scanBatchSize) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const { firstSeenSec, firstSeenSecWithoutNames, known } of dictionaryWords.values()) {
|
||||||
|
const hasNonNameForm = firstSeenSecWithoutNames !== null;
|
||||||
|
summary.uniqueWords += 1;
|
||||||
|
if (hasNonNameForm) summary.uniqueWordsWithoutNames += 1;
|
||||||
|
if (firstSeenSec >= weekAgoSec) summary.newThisWeek += 1;
|
||||||
|
if (hasNonNameForm && firstSeenSecWithoutNames >= weekAgoSec) {
|
||||||
|
summary.newThisWeekWithoutNames += 1;
|
||||||
|
}
|
||||||
|
if (known) {
|
||||||
|
summary.knownWordCount! += 1;
|
||||||
|
if (hasNonNameForm) summary.knownWordCountWithoutNames! += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +493,12 @@ export function getWordOccurrences(
|
|||||||
reading: string,
|
reading: string,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
allForms = false,
|
||||||
): WordOccurrenceRow[] {
|
): WordOccurrenceRow[] {
|
||||||
|
const wordFilter = allForms
|
||||||
|
? `w.id IN (SELECT id FROM imm_words WHERE ${DICTIONARY_WORD_KEY_SQL} = ?)`
|
||||||
|
: 'w.headword = ? AND w.word = ? AND w.reading = ?';
|
||||||
|
const wordParams = allForms ? [headword] : [headword, word, reading];
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
@@ -419,13 +520,13 @@ export function getWordOccurrences(
|
|||||||
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
|
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
|
||||||
JOIN imm_videos v ON v.video_id = l.video_id
|
JOIN imm_videos v ON v.video_id = l.video_id
|
||||||
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
|
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
|
||||||
WHERE w.headword = ? AND w.word = ? AND w.reading = ?
|
WHERE ${wordFilter}
|
||||||
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
|
ORDER BY ${MINEABLE_LINES_FIRST_SQL}, l.CREATED_DATE DESC, l.line_id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
OFFSET ?
|
OFFSET ?
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all(headword, word, reading, limit, offset) as unknown as WordOccurrenceRow[];
|
.all(...wordParams, limit, offset) as unknown as WordOccurrenceRow[];
|
||||||
return rows.map(widenLineText);
|
return rows.map(widenLineText);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,15 +590,16 @@ export function searchSubtitleSentences(
|
|||||||
narrowFullwidthKana(widenedTerm),
|
narrowFullwidthKana(widenedTerm),
|
||||||
]).map((variant) => `%${escapeLikeTerm(variant)}%`);
|
]).map((variant) => `%${escapeLikeTerm(variant)}%`);
|
||||||
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
|
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
|
||||||
|
// Uncorrelated on purpose: SQLite resolves the headword's lines once through
|
||||||
|
// the word indexes. A per-line EXISTS probed every subtitle line (~0.2 s).
|
||||||
const headwordClause =
|
const headwordClause =
|
||||||
headwords.length > 0
|
headwords.length > 0
|
||||||
? `
|
? `
|
||||||
OR EXISTS (
|
OR l.line_id IN (
|
||||||
SELECT 1
|
SELECT o.line_id
|
||||||
FROM imm_word_line_occurrences o
|
FROM imm_words w
|
||||||
JOIN imm_words w ON w.id = o.word_id
|
JOIN imm_word_line_occurrences o ON o.word_id = w.id
|
||||||
WHERE o.line_id = l.line_id
|
WHERE w.headword IN (${headwords.map(() => '?').join(', ')})
|
||||||
AND w.headword IN (${headwords.map(() => '?').join(', ')})
|
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
: '';
|
: '';
|
||||||
@@ -531,7 +633,7 @@ export function searchSubtitleSentences(
|
|||||||
JOIN imm_videos v ON v.video_id = l.video_id
|
JOIN imm_videos v ON v.video_id = l.video_id
|
||||||
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
|
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
|
||||||
WHERE ${clauses.join(' AND ')}
|
WHERE ${clauses.join(' AND ')}
|
||||||
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
|
ORDER BY ${MINEABLE_LINES_FIRST_SQL}, l.CREATED_DATE DESC, l.line_id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
@@ -576,17 +678,99 @@ export function getSessionEvents(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORD_DETAIL_FORM_LIMIT = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry per written form. A form can be stored more than once when its reading
|
||||||
|
* was recorded differently, as 見て is under both みて and み. Returns most seen first.
|
||||||
|
*/
|
||||||
|
export function mergeFormsByWord<T extends { word: string; seen: number }>(forms: T[]): T[] {
|
||||||
|
const byWord = new Map<string, T>();
|
||||||
|
for (const form of forms) {
|
||||||
|
const existing = byWord.get(form.word);
|
||||||
|
if (existing) {
|
||||||
|
existing.seen += form.seen;
|
||||||
|
} else {
|
||||||
|
byWord.set(form.word, { ...form });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byWord.values()].sort((left, right) => right.seen - left.seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A word's form plus totals across every form of its dictionary word. */
|
||||||
export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null {
|
export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null {
|
||||||
return db
|
const word = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT id AS wordId, headword, word, reading,
|
SELECT id AS wordId, COALESCE(headword, '') AS headword, COALESCE(word, '') AS word,
|
||||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
COALESCE(reading, '') AS reading,
|
||||||
frequency, first_seen AS firstSeen, last_seen AS lastSeen
|
part_of_speech AS partOfSpeech, pos1, pos2, pos3
|
||||||
FROM imm_words WHERE id = ?
|
FROM imm_words WHERE id = ?
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get(wordId) as WordDetailRow | null;
|
.get(wordId) as Pick<
|
||||||
|
WordDetailRow,
|
||||||
|
'wordId' | 'headword' | 'word' | 'reading' | 'partOfSpeech' | 'pos1' | 'pos2' | 'pos3'
|
||||||
|
> | null;
|
||||||
|
if (!word) return null;
|
||||||
|
|
||||||
|
const hasHeadword = word.headword.trim() !== '';
|
||||||
|
const forms = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id AS wordId, COALESCE(word, '') AS word, COALESCE(reading, '') AS reading,
|
||||||
|
frequency AS seen, frequency_rank AS rank,
|
||||||
|
first_seen AS firstSeen, last_seen AS lastSeen
|
||||||
|
FROM imm_words
|
||||||
|
WHERE ${hasHeadword ? '(headword = ? AND vocabulary_visible = 1) OR id = ?' : 'id = ?'}
|
||||||
|
ORDER BY frequency DESC, id
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(...(hasHeadword ? [word.headword, wordId] : [wordId])) as Array<{
|
||||||
|
wordId: number;
|
||||||
|
word: string;
|
||||||
|
reading: string;
|
||||||
|
seen: number | null;
|
||||||
|
rank: number | null;
|
||||||
|
firstSeen: number;
|
||||||
|
lastSeen: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const lines = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT COUNT(DISTINCT l.line_id) AS lineCount,
|
||||||
|
COUNT(DISTINCT CASE WHEN ${NO_LOCAL_FILE_SQL} THEN l.line_id END) AS streamLineCount
|
||||||
|
FROM imm_word_line_occurrences o
|
||||||
|
JOIN imm_words w ON w.id = o.word_id
|
||||||
|
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
|
||||||
|
JOIN imm_videos v ON v.video_id = l.video_id
|
||||||
|
WHERE ${hasHeadword ? 'w.headword = ?' : 'w.id = ?'}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get(hasHeadword ? word.headword : wordId) as { lineCount: number; streamLineCount: number };
|
||||||
|
const merged = mergeFormsByWord(forms.map((form) => ({ ...form, seen: form.seen ?? 0 })));
|
||||||
|
const toSeconds = (value: number): number => timestampSeconds(fromDbTimestamp(value) ?? 0);
|
||||||
|
const ranks = forms.map((form) => form.rank).filter((rank): rank is number => (rank ?? 0) > 0);
|
||||||
|
return {
|
||||||
|
...word,
|
||||||
|
frequency: forms.reduce((sum, form) => sum + (form.seen ?? 0), 0),
|
||||||
|
frequencyRank: ranks.length > 0 ? Math.min(...ranks) : null,
|
||||||
|
dictionaryReading:
|
||||||
|
forms.find((form) => form.word === word.headword && form.reading.trim() !== '')?.reading ??
|
||||||
|
'',
|
||||||
|
formCount: merged.length,
|
||||||
|
forms: merged.slice(0, WORD_DETAIL_FORM_LIMIT).map((form) => ({
|
||||||
|
wordId: form.wordId,
|
||||||
|
word: form.word,
|
||||||
|
reading: form.reading,
|
||||||
|
seen: form.seen,
|
||||||
|
})),
|
||||||
|
lineCount: lines.lineCount,
|
||||||
|
streamLineCount: lines.streamLineCount,
|
||||||
|
firstSeen: Math.min(...forms.map((form) => toSeconds(form.firstSeen))),
|
||||||
|
lastSeen: Math.max(...forms.map((form) => toSeconds(form.lastSeen))),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWordAnimeAppearances(
|
export function getWordAnimeAppearances(
|
||||||
@@ -599,21 +783,26 @@ export function getWordAnimeAppearances(
|
|||||||
SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle,
|
SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle,
|
||||||
SUM(o.occurrence_count) AS occurrenceCount
|
SUM(o.occurrence_count) AS occurrenceCount
|
||||||
FROM imm_word_line_occurrences o
|
FROM imm_word_line_occurrences o
|
||||||
|
JOIN imm_words w ON w.id = o.word_id
|
||||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||||
JOIN imm_anime a ON a.anime_id = sl.anime_id
|
JOIN imm_anime a ON a.anime_id = sl.anime_id
|
||||||
WHERE o.word_id = ? AND sl.anime_id IS NOT NULL
|
WHERE (w.id = ? OR w.headword = (SELECT NULLIF(TRIM(headword), '') FROM imm_words WHERE id = ?))
|
||||||
|
AND sl.anime_id IS NOT NULL
|
||||||
GROUP BY a.anime_id
|
GROUP BY a.anime_id
|
||||||
ORDER BY occurrenceCount DESC
|
ORDER BY occurrenceCount DESC
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all(wordId) as WordAnimeAppearanceRow[];
|
.all(wordId, wordId) as WordAnimeAppearanceRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Other dictionary words sharing a reading or kanji; a word's own forms don't count. */
|
||||||
export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): SimilarWordRow[] {
|
export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): SimilarWordRow[] {
|
||||||
const word = db.prepare('SELECT headword, reading FROM imm_words WHERE id = ?').get(wordId) as {
|
const word = db
|
||||||
headword: string;
|
.prepare(
|
||||||
reading: string;
|
`SELECT COALESCE(headword, '') AS headword, COALESCE(reading, '') AS reading
|
||||||
} | null;
|
FROM imm_words WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.get(wordId) as { headword: string; reading: string } | null;
|
||||||
if (!word || word.headword.trim() === '') return [];
|
if (!word || word.headword.trim() === '') return [];
|
||||||
|
|
||||||
const clauses: string[] = [];
|
const clauses: string[] = [];
|
||||||
@@ -631,22 +820,41 @@ export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): S
|
|||||||
|
|
||||||
if (clauses.length === 0) return [];
|
if (clauses.length === 0) return [];
|
||||||
|
|
||||||
const orderBy =
|
const readingMatch =
|
||||||
reading !== '' ? 'CASE WHEN reading = ? THEN 0 ELSE 1 END, frequency DESC' : 'frequency DESC';
|
reading !== ''
|
||||||
const orderParams = reading !== '' ? [word.reading] : [];
|
? 'MAX(CASE WHEN reading = ? THEN 1 ELSE 0 END) OVER (PARTITION BY headword)'
|
||||||
|
: '0';
|
||||||
|
const readingMatchParams = reading !== '' ? [word.reading] : [];
|
||||||
|
|
||||||
|
// One form matching is enough to relate a word, but its total and the form it shows
|
||||||
|
// come from all its visible forms, so a rare form with the same reading can't stand in.
|
||||||
|
// The blank check lists its characters because SQL TRIM alone strips only ASCII spaces.
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT id AS wordId, headword, word, reading, frequency
|
WITH related AS (
|
||||||
FROM imm_words
|
SELECT DISTINCT headword
|
||||||
WHERE id != ?
|
FROM imm_words
|
||||||
AND (${clauses.join(' OR ')})
|
WHERE headword != ? AND vocabulary_visible = 1
|
||||||
ORDER BY ${orderBy}
|
AND TRIM(headword, ' ' || char(9, 10, 11, 12, 13, 12288)) != ''
|
||||||
|
AND (${clauses.join(' OR ')})
|
||||||
|
),
|
||||||
|
forms AS (
|
||||||
|
SELECT id, headword, word, reading,
|
||||||
|
SUM(frequency) OVER (PARTITION BY headword) AS totalSeen,
|
||||||
|
${readingMatch} AS readingMatch,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY headword ORDER BY frequency DESC, id) AS formOrder
|
||||||
|
FROM imm_words
|
||||||
|
WHERE vocabulary_visible = 1 AND headword IN (SELECT headword FROM related)
|
||||||
|
)
|
||||||
|
SELECT id AS wordId, headword, word, reading, totalSeen AS frequency
|
||||||
|
FROM forms
|
||||||
|
WHERE formOrder = 1
|
||||||
|
ORDER BY readingMatch DESC, totalSeen DESC, headword
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all(wordId, ...params, ...orderParams, limit) as SimilarWordRow[];
|
.all(word.headword, ...params, ...readingMatchParams, limit) as SimilarWordRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
|
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import {
|
import {
|
||||||
ACTIVE_SESSION_METRICS_CTE,
|
ACTIVE_SESSION_METRICS_CTE,
|
||||||
SESSION_WORD_COUNTS_SELECT,
|
|
||||||
fromDbTimestamp,
|
fromDbTimestamp,
|
||||||
resolvedCoverBlobExpr,
|
resolvedCoverBlobExpr,
|
||||||
sessionDisplayWordsExpr,
|
sessionDisplayWordsExpr,
|
||||||
@@ -123,7 +122,7 @@ export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): Anime
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] {
|
export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] {
|
||||||
const wordsExpr = sessionDisplayWordsExpr('s', 'swc', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
|
const wordsExpr = sessionDisplayWordsExpr('s', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
@@ -184,7 +183,6 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
|||||||
FROM imm_videos v
|
FROM imm_videos v
|
||||||
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
||||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||||
LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id
|
|
||||||
WHERE v.anime_id = ?
|
WHERE v.anime_id = ?
|
||||||
GROUP BY v.video_id
|
GROUP BY v.video_id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -250,7 +248,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null {
|
export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null {
|
||||||
const wordsExpr = sessionDisplayWordsExpr('s', 'swc', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
|
const wordsExpr = sessionDisplayWordsExpr('s', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
@@ -298,7 +296,6 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
|
|||||||
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||||
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
||||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||||
LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id
|
|
||||||
WHERE v.video_id = ?
|
WHERE v.video_id = ?
|
||||||
AND (lm.video_id IS NOT NULL OR s.session_id IS NOT NULL)
|
AND (lm.video_id IS NOT NULL OR s.session_id IS NOT NULL)
|
||||||
GROUP BY v.video_id
|
GROUP BY v.video_id
|
||||||
@@ -312,7 +309,7 @@ export function getMediaSessions(
|
|||||||
videoId: number,
|
videoId: number,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
): SessionSummaryQueryRow[] {
|
): SessionSummaryQueryRow[] {
|
||||||
const wordsExpr = sessionDisplayWordsExpr('s', 'swc', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
|
const wordsExpr = sessionDisplayWordsExpr('s', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
@@ -333,7 +330,6 @@ export function getMediaSessions(
|
|||||||
COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0) AS yomitanLookupCount
|
COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0) AS yomitanLookupCount
|
||||||
FROM imm_sessions s
|
FROM imm_sessions s
|
||||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||||
LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id
|
|
||||||
LEFT JOIN imm_videos v ON v.video_id = s.video_id
|
LEFT JOIN imm_videos v ON v.video_id = s.video_id
|
||||||
WHERE s.video_id = ?
|
WHERE s.video_id = ?
|
||||||
ORDER BY s.started_at_ms DESC
|
ORDER BY s.started_at_ms DESC
|
||||||
@@ -358,12 +354,15 @@ export function getMediaDailyRollups(
|
|||||||
videoId: number,
|
videoId: number,
|
||||||
limit = 90,
|
limit = 90,
|
||||||
): ImmersionSessionRollupRow[] {
|
): ImmersionSessionRollupRow[] {
|
||||||
const wordsExpr = sessionDisplayWordsExpr('s', 'swc');
|
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
WITH session_word_counts AS (
|
WITH recent_days AS (
|
||||||
${SESSION_WORD_COUNTS_SELECT}
|
SELECT DISTINCT rollup_day
|
||||||
|
FROM imm_daily_rollups
|
||||||
|
WHERE video_id = ?
|
||||||
|
ORDER BY rollup_day DESC
|
||||||
|
LIMIT ?
|
||||||
),
|
),
|
||||||
daily_word_counts AS (
|
daily_word_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -372,18 +371,15 @@ export function getMediaDailyRollups(
|
|||||||
AS INTEGER
|
AS INTEGER
|
||||||
) AS rollupDay,
|
) AS rollupDay,
|
||||||
s.video_id AS videoId,
|
s.video_id AS videoId,
|
||||||
SUM(${wordsExpr}) AS totalTokensSeen
|
SUM(${sessionDisplayWordsExpr('s')}) AS totalTokensSeen
|
||||||
FROM imm_sessions s
|
FROM imm_sessions s
|
||||||
LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id
|
|
||||||
WHERE s.ended_at_ms IS NOT NULL
|
WHERE s.ended_at_ms IS NOT NULL
|
||||||
|
AND s.video_id = ?
|
||||||
|
AND CAST(
|
||||||
|
julianday(CAST(s.started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
|
||||||
|
AS INTEGER
|
||||||
|
) >= (SELECT MIN(rollup_day) FROM recent_days)
|
||||||
GROUP BY rollupDay, s.video_id
|
GROUP BY rollupDay, s.video_id
|
||||||
),
|
|
||||||
recent_days AS (
|
|
||||||
SELECT DISTINCT rollup_day
|
|
||||||
FROM imm_daily_rollups
|
|
||||||
WHERE video_id = ?
|
|
||||||
ORDER BY rollup_day DESC
|
|
||||||
LIMIT ?
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
rollup_day AS rollupDayOrMonth,
|
rollup_day AS rollupDayOrMonth,
|
||||||
@@ -416,7 +412,7 @@ export function getMediaDailyRollups(
|
|||||||
ORDER BY rollup_day DESC, video_id DESC
|
ORDER BY rollup_day DESC, video_id DESC
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all(videoId, limit, videoId) as unknown as ImmersionSessionRollupRow[];
|
.all(videoId, limit, videoId, videoId) as unknown as ImmersionSessionRollupRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnimeDailyRollups(
|
export function getAnimeDailyRollups(
|
||||||
@@ -424,12 +420,16 @@ export function getAnimeDailyRollups(
|
|||||||
animeId: number,
|
animeId: number,
|
||||||
limit = 90,
|
limit = 90,
|
||||||
): ImmersionSessionRollupRow[] {
|
): ImmersionSessionRollupRow[] {
|
||||||
const wordsExpr = sessionDisplayWordsExpr('s', 'swc');
|
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
WITH session_word_counts AS (
|
WITH recent_days AS (
|
||||||
${SESSION_WORD_COUNTS_SELECT}
|
SELECT DISTINCT r.rollup_day
|
||||||
|
FROM imm_daily_rollups r
|
||||||
|
JOIN imm_videos v ON v.video_id = r.video_id
|
||||||
|
WHERE v.anime_id = ?
|
||||||
|
ORDER BY r.rollup_day DESC
|
||||||
|
LIMIT ?
|
||||||
),
|
),
|
||||||
daily_word_counts AS (
|
daily_word_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -438,19 +438,15 @@ export function getAnimeDailyRollups(
|
|||||||
AS INTEGER
|
AS INTEGER
|
||||||
) AS rollupDay,
|
) AS rollupDay,
|
||||||
s.video_id AS videoId,
|
s.video_id AS videoId,
|
||||||
SUM(${wordsExpr}) AS totalTokensSeen
|
SUM(${sessionDisplayWordsExpr('s')}) AS totalTokensSeen
|
||||||
FROM imm_sessions s
|
FROM imm_sessions s
|
||||||
LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id
|
|
||||||
WHERE s.ended_at_ms IS NOT NULL
|
WHERE s.ended_at_ms IS NOT NULL
|
||||||
|
AND s.video_id IN (SELECT video_id FROM imm_videos WHERE anime_id = ?)
|
||||||
|
AND CAST(
|
||||||
|
julianday(CAST(s.started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
|
||||||
|
AS INTEGER
|
||||||
|
) >= (SELECT MIN(rollup_day) FROM recent_days)
|
||||||
GROUP BY rollupDay, s.video_id
|
GROUP BY rollupDay, s.video_id
|
||||||
),
|
|
||||||
recent_days AS (
|
|
||||||
SELECT DISTINCT r.rollup_day
|
|
||||||
FROM imm_daily_rollups r
|
|
||||||
JOIN imm_videos v ON v.video_id = r.video_id
|
|
||||||
WHERE v.anime_id = ?
|
|
||||||
ORDER BY r.rollup_day DESC
|
|
||||||
LIMIT ?
|
|
||||||
)
|
)
|
||||||
SELECT r.rollup_day AS rollupDayOrMonth, r.video_id AS videoId,
|
SELECT r.rollup_day AS rollupDayOrMonth, r.video_id AS videoId,
|
||||||
r.total_sessions AS totalSessions, r.total_active_min AS totalActiveMin,
|
r.total_sessions AS totalSessions, r.total_active_min AS totalActiveMin,
|
||||||
@@ -481,7 +477,7 @@ export function getAnimeDailyRollups(
|
|||||||
ORDER BY r.rollup_day DESC, r.video_id DESC
|
ORDER BY r.rollup_day DESC, r.video_id DESC
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all(animeId, limit, animeId) as unknown as ImmersionSessionRollupRow[];
|
.all(animeId, limit, animeId, animeId) as unknown as ImmersionSessionRollupRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null {
|
export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null {
|
||||||
@@ -657,7 +653,7 @@ export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] {
|
export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] {
|
||||||
const wordsExpr = sessionDisplayWordsExpr('s', 'swc', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
|
const wordsExpr = sessionDisplayWordsExpr('s', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
@@ -677,7 +673,6 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu
|
|||||||
FROM imm_sessions s
|
FROM imm_sessions s
|
||||||
JOIN imm_videos v ON v.video_id = s.video_id
|
JOIN imm_videos v ON v.video_id = s.video_id
|
||||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||||
LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id
|
|
||||||
WHERE s.video_id = ?
|
WHERE s.video_id = ?
|
||||||
ORDER BY s.started_at_ms DESC
|
ORDER BY s.started_at_ms DESC
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { deleteMaintenanceBatch } from './query-delete-maintenance';
|
|||||||
import { PartOfSpeech, type MergedToken } from '../../../types';
|
import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||||
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
||||||
|
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
|
||||||
import {
|
import {
|
||||||
cleanupUnusedCoverArtBlobHash,
|
cleanupUnusedCoverArtBlobHash,
|
||||||
findSharedCoverBlobHash,
|
findSharedCoverBlobHash,
|
||||||
@@ -241,6 +242,24 @@ export async function cleanupVocabularyStats(
|
|||||||
const deleteOccurrencesStmt = db.prepare(
|
const deleteOccurrencesStmt = db.prepare(
|
||||||
'DELETE FROM imm_word_line_occurrences WHERE word_id = ?',
|
'DELETE FROM imm_word_line_occurrences WHERE word_id = ?',
|
||||||
);
|
);
|
||||||
|
const selectVisibilityInputsStmt = db.prepare(
|
||||||
|
`SELECT word, headword, reading, part_of_speech AS partOfSpeech,
|
||||||
|
pos1, pos2, pos3, frequency_rank AS frequencyRank
|
||||||
|
FROM imm_words
|
||||||
|
WHERE id = ?`,
|
||||||
|
);
|
||||||
|
const updateVisibilityStmt = db.prepare(
|
||||||
|
`UPDATE imm_words SET vocabulary_visible = ? WHERE id = ? AND vocabulary_visible IS NOT ?`,
|
||||||
|
);
|
||||||
|
// Repairs rewrite the visibility predicate's inputs, and the vocabulary summary
|
||||||
|
// and new-word rollups read the persisted result, so re-derive it after each one.
|
||||||
|
const syncWordVisibility = (wordId: number): void => {
|
||||||
|
// The scan awaits POS resolution, so another writer may have removed the word.
|
||||||
|
const stored = selectVisibilityInputsStmt.get(wordId) as VocabularyVisibilityRow | null;
|
||||||
|
if (!stored) return;
|
||||||
|
const visible = isVocabularyStatsRowVisible(stored) ? 1 : 0;
|
||||||
|
updateVisibilityStmt.run(visible, wordId, visible);
|
||||||
|
};
|
||||||
let kept = 0;
|
let kept = 0;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
let repaired = 0;
|
let repaired = 0;
|
||||||
@@ -279,6 +298,7 @@ export async function cleanupVocabularyStats(
|
|||||||
row.last_seen ?? duplicate.last_seen ?? 0,
|
row.last_seen ?? duplicate.last_seen ?? 0,
|
||||||
duplicate.id,
|
duplicate.id,
|
||||||
);
|
);
|
||||||
|
syncWordVisibility(duplicate.id);
|
||||||
deleteStmt.run(row.id);
|
deleteStmt.run(row.id);
|
||||||
repaired += 1;
|
repaired += 1;
|
||||||
deleted += 1;
|
deleted += 1;
|
||||||
@@ -294,6 +314,7 @@ export async function cleanupVocabularyStats(
|
|||||||
resolvedPos.pos3,
|
resolvedPos.pos3,
|
||||||
row.id,
|
row.id,
|
||||||
);
|
);
|
||||||
|
syncWordVisibility(row.id);
|
||||||
repaired += 1;
|
repaired += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 470 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user