mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-16 15:13:31 -07:00
Compare commits
5 Commits
v0.17.0-beta.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
d199376364
|
|||
|
3aee88c150
|
|||
| 70da3ee8bd | |||
|
aa8eb753f6
|
|||
|
8d73de8731
|
@@ -1,5 +1,40 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.17.0 (2026-06-15)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||||
|
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Anki – Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
|
||||||
|
- **Anki – Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
|
||||||
|
- **Anki – Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
|
||||||
|
- **Character Dictionary – Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
|
||||||
|
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
|
||||||
|
- **Overlay – macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
|
||||||
|
- **Overlay – Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
|
||||||
|
- **Overlay – Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||||
|
- **Overlay – Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
|
||||||
|
- **Stats – AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
|
||||||
|
- **Updates – Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||||
|
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.16.0 (2026-06-10)
|
## v0.16.0 (2026-06-10)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: notifications
|
|
||||||
|
|
||||||
- Restored the SubMiner app icon on system notifications that do not provide a custom notification image.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: updates
|
|
||||||
|
|
||||||
- Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets if either one is missing.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: updates
|
|
||||||
|
|
||||||
- Fixed Linux updates so the managed support-asset install now creates and refreshes both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates.
|
|
||||||
- Fixed Linux support-asset refreshes so unrelated SubMiner data directories are left alone and plugin copies are staged before replacing the live runtime plugin.
|
|
||||||
- Fixed first Linux launcher playback on fresh installs by auto-installing the managed runtime plugin copy and rofi theme from the bundled app before mpv starts when either support asset is missing.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed macOS Yomitan popup focus after card mining or popup reload while still allowing click-away to close the popup without a hide/reappear cycle.
|
|
||||||
- Fixed macOS Yomitan popups staying open when clicking transparent overlay space; click-away is captured for popup close, then passthrough returns to mpv.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed auto-paused Linux visible overlay startup so the overlay stays interactive during the first measurement gap, startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv `sub-text` refreshes parsed cues before synthetic warm readiness can resume playback.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Updated default overlay subtitle delay/step bindings to match mpv: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show subtitle delay on the OSD. Removed the old SubMiner-only adjacent-cue delay action.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Kept the visible overlay active while mpv advances to the next playlist item, even when the next episode loads after the warm transition delay.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Kept the GitHub release `What's Changed` and `New Contributors` attribution sections when CI regenerates release notes from the committed changelog.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: runtime
|
|
||||||
|
|
||||||
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
|
|
||||||
- Hardened split runtime helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Fixed manual AniList linking from the stats anime page so automatic searches drop the generated `Season N` suffix and search only the anime title.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: updates
|
|
||||||
|
|
||||||
- New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
|
||||||
+43
-3
@@ -1,6 +1,46 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.16.0 (2026-06-10)
|
## v0.17.0 (2026-06-15)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
|
||||||
|
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||||
|
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- **Anki – Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
|
||||||
|
- **Anki – Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
|
||||||
|
- **Anki – Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
|
||||||
|
- **Character Dictionary – Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
|
||||||
|
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
|
||||||
|
- **Overlay – macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
|
||||||
|
- **Overlay – Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
|
||||||
|
- **Overlay – Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||||
|
- **Overlay – Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
|
||||||
|
- **Stats – AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
|
||||||
|
- **Updates – Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
|
||||||
|
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
|
||||||
|
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||||
|
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Previous Versions
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.16.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.16.0 (2026-06-10)</h2>
|
||||||
|
|
||||||
**Breaking Changes**
|
**Breaking Changes**
|
||||||
|
|
||||||
@@ -24,7 +64,7 @@
|
|||||||
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
||||||
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
||||||
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
||||||
- **Update Notification Default**: New installs default `notificationType` to `overlay`, while `both` remains available for overlay + system notifications.
|
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications.
|
||||||
|
|
||||||
**Fixed**
|
**Fixed**
|
||||||
|
|
||||||
@@ -48,7 +88,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Previous Versions
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>v0.15.x</summary>
|
<summary>v0.15.x</summary>
|
||||||
|
|||||||
+4
-2
@@ -61,8 +61,10 @@
|
|||||||
committed file — so review it before committing. If you add more
|
committed file — so review it before committing. If you add more
|
||||||
`changes/*.md` fragments for a later beta/RC, rerun
|
`changes/*.md` fragments for a later beta/RC, rerun
|
||||||
`bun run changelog:prerelease-notes --version <version>`; the generator uses
|
`bun run changelog:prerelease-notes --version <version>`; the generator uses
|
||||||
the existing prerelease notes as the baseline and asks Claude to merge only
|
the existing prerelease notes as the baseline only when their hidden
|
||||||
the new fragment material. Do not run `bun run changelog:build`.
|
`prerelease-base-version` marker matches the current base version, and asks
|
||||||
|
Claude to merge only the new fragment material. Do not run
|
||||||
|
`bun run changelog:build`.
|
||||||
6. Tag the commit: `git tag v<version>`.
|
6. Tag the commit: `git tag v<version>`.
|
||||||
7. Push commit + tag.
|
7. Push commit + tag.
|
||||||
|
|
||||||
|
|||||||
+16
-12
@@ -554,13 +554,15 @@ test(
|
|||||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||||
|
|
||||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||||
assert.equal(appEntries.length > 0, true);
|
if (process.platform === 'linux') {
|
||||||
assert.equal(
|
assert.equal(appEntries.length > 0, true);
|
||||||
appEntries.every((entry) =>
|
assert.equal(
|
||||||
(entry.argv as string[]).includes('--ensure-linux-runtime-plugin-assets'),
|
appEntries.every((entry) =>
|
||||||
),
|
(entry.argv as string[]).includes('--ensure-linux-runtime-plugin-assets'),
|
||||||
true,
|
),
|
||||||
);
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
assert.equal(appStartEntries.length, 0);
|
assert.equal(appStartEntries.length, 0);
|
||||||
assert.equal(appStopEntries.length, 0);
|
assert.equal(appStopEntries.length, 0);
|
||||||
assert.equal(controlEntries.length, 1);
|
assert.equal(controlEntries.length, 1);
|
||||||
@@ -617,11 +619,13 @@ test(
|
|||||||
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
|
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
|
||||||
);
|
);
|
||||||
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
||||||
assert.match(result.stdout, /managed plugin\/theme assets/i);
|
if (process.platform === 'linux') {
|
||||||
assert.equal(
|
assert.match(result.stdout, /managed plugin\/theme assets/i);
|
||||||
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
assert.equal(
|
||||||
true,
|
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
||||||
);
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const node_os_1 = __importDefault(require("node:os"));
|
||||||
|
const node_child_process_1 = require("node:child_process");
|
||||||
|
const electron_1 = require("electron");
|
||||||
|
const help_1 = require("./cli/help");
|
||||||
|
const main_entry_runtime_1 = require("./main-entry-runtime");
|
||||||
|
const early_single_instance_1 = require("./main/early-single-instance");
|
||||||
|
const main_entry_launch_config_1 = require("./main-entry-launch-config");
|
||||||
|
const app_control_client_1 = require("./shared/app-control-client");
|
||||||
|
const first_run_setup_plugin_1 = require("./main/runtime/first-run-setup-plugin");
|
||||||
|
const windows_mpv_launch_1 = require("./main/runtime/windows-mpv-launch");
|
||||||
|
const stats_daemon_entry_1 = require("./stats-daemon-entry");
|
||||||
|
const fatal_error_1 = require("./main/fatal-error");
|
||||||
|
const mpv_logging_args_1 = require("./shared/mpv-logging-args");
|
||||||
|
const log_files_1 = require("./shared/log-files");
|
||||||
|
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||||
|
function appendWindowsMpvLaunchLog(message, logRotation) {
|
||||||
|
if (!(0, log_files_1.isLogFileEnabled)('app')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
(0, log_files_1.appendLogLine)(process.env.SUBMINER_APP_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('app'), `[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`, { rotation: logRotation });
|
||||||
|
}
|
||||||
|
function applySanitizedEnv(sanitizedEnv) {
|
||||||
|
if (sanitizedEnv.NODE_NO_WARNINGS) {
|
||||||
|
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||||
|
}
|
||||||
|
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||||
|
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
delete process.env.VK_INSTANCE_LAYERS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resolveBundledWindowsMpvPluginEntrypoint() {
|
||||||
|
return ((0, first_run_setup_plugin_1.resolvePackagedRuntimePluginPath)({
|
||||||
|
dirname: __dirname,
|
||||||
|
appPath: electron_1.app.getAppPath(),
|
||||||
|
resourcesPath: process.resourcesPath,
|
||||||
|
}) ?? undefined);
|
||||||
|
}
|
||||||
|
function buildInstalledWindowsMpvPluginMessage(pathValue, version) {
|
||||||
|
return [
|
||||||
|
'SubMiner detected an installed mpv plugin at:',
|
||||||
|
pathValue,
|
||||||
|
'',
|
||||||
|
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
|
||||||
|
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
async function promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection) {
|
||||||
|
const response = await electron_1.dialog.showMessageBox({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'SubMiner mpv plugin detected',
|
||||||
|
message: buildInstalledWindowsMpvPluginMessage(detection.path ?? 'unknown path', detection.version),
|
||||||
|
detail: 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
|
||||||
|
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 2,
|
||||||
|
});
|
||||||
|
if (response.response === 2) {
|
||||||
|
return 'cancel';
|
||||||
|
}
|
||||||
|
if (response.response === 1) {
|
||||||
|
return 'continue';
|
||||||
|
}
|
||||||
|
const candidates = (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({
|
||||||
|
platform: 'win32',
|
||||||
|
homeDir: node_os_1.default.homedir(),
|
||||||
|
appDataDir: electron_1.app.getPath('appData'),
|
||||||
|
mpvExecutablePath: mpvPath,
|
||||||
|
});
|
||||||
|
const result = await (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({
|
||||||
|
candidates,
|
||||||
|
trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath),
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
await electron_1.dialog.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: 'Legacy mpv plugin removed',
|
||||||
|
message: 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||||
|
});
|
||||||
|
return 'removed';
|
||||||
|
}
|
||||||
|
await electron_1.dialog.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Could not remove legacy mpv plugin',
|
||||||
|
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
|
||||||
|
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
|
||||||
|
});
|
||||||
|
return 'cancel';
|
||||||
|
}
|
||||||
|
function createWindowsRuntimePluginPolicy() {
|
||||||
|
return {
|
||||||
|
detectInstalledMpvPlugin: (mpvPath) => (0, first_run_setup_plugin_1.detectInstalledMpvPlugin)({
|
||||||
|
platform: 'win32',
|
||||||
|
homeDir: node_os_1.default.homedir(),
|
||||||
|
appDataDir: electron_1.app.getPath('appData'),
|
||||||
|
mpvExecutablePath: mpvPath,
|
||||||
|
}),
|
||||||
|
notifyInstalledPluginDetected: (detection) => {
|
||||||
|
if (!detection.installed || !detection.path)
|
||||||
|
return;
|
||||||
|
electron_1.dialog.showMessageBoxSync({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'SubMiner mpv plugin detected',
|
||||||
|
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
process.argv = (0, main_entry_runtime_1.normalizeStartupArgv)(process.argv, process.env);
|
||||||
|
(0, main_entry_runtime_1.applyEarlyLinuxCommandLineSwitches)(electron_1.app.commandLine, process.argv);
|
||||||
|
applySanitizedEnv((0, main_entry_runtime_1.sanitizeStartupEnv)(process.env));
|
||||||
|
const userDataPath = (0, main_entry_runtime_1.configureEarlyAppPaths)(electron_1.app);
|
||||||
|
const reportFatalError = (0, fatal_error_1.createFatalErrorReporter)({
|
||||||
|
showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details),
|
||||||
|
consoleError: (message, error) => console.error(message, error),
|
||||||
|
});
|
||||||
|
(0, fatal_error_1.registerFatalErrorHandlers)({
|
||||||
|
reportFatalError,
|
||||||
|
exit: (code) => electron_1.app.exit(code),
|
||||||
|
});
|
||||||
|
function startMainProcess() {
|
||||||
|
const gotSingleInstanceLock = (0, early_single_instance_1.requestSingleInstanceLockEarly)(electron_1.app);
|
||||||
|
if (!gotSingleInstanceLock) {
|
||||||
|
electron_1.app.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
require('./main.js');
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
reportFatalError(error, {
|
||||||
|
title: 'SubMiner startup failed',
|
||||||
|
context: 'SubMiner failed while loading the main process.',
|
||||||
|
});
|
||||||
|
electron_1.app.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function forwardStartupArgvViaAppControlIfAvailable() {
|
||||||
|
if (!(0, main_entry_runtime_1.shouldForwardStartupArgvViaAppControl)(process.argv, process.env)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = await (0, app_control_client_1.sendAppControlCommand)(process.argv, {
|
||||||
|
configDir: userDataPath,
|
||||||
|
timeoutMs: 500,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
electron_1.app.exit(0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!result.unavailable) {
|
||||||
|
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
|
||||||
|
electron_1.app.exit(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async function runEntryProcess() {
|
||||||
|
if ((0, main_entry_runtime_1.shouldHandleHelpOnlyAtEntry)(process.argv, process.env)) {
|
||||||
|
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeHelpEnv)(process.env);
|
||||||
|
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||||
|
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||||
|
delete process.env.VK_INSTANCE_LAYERS;
|
||||||
|
}
|
||||||
|
(0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT);
|
||||||
|
process.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((0, main_entry_runtime_1.shouldHandleLaunchMpvAtEntry)(process.argv, process.env)) {
|
||||||
|
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeLaunchMpvEnv)(process.env);
|
||||||
|
applySanitizedEnv(sanitizedEnv);
|
||||||
|
await electron_1.app.whenReady();
|
||||||
|
const configuredMpvLaunch = (0, main_entry_launch_config_1.readConfiguredWindowsMpvLaunch)(userDataPath);
|
||||||
|
const extraArgs = (0, main_entry_runtime_1.normalizeLaunchMpvExtraArgs)(process.argv);
|
||||||
|
(0, log_files_1.applyLogFileTogglesToEnv)(configuredMpvLaunch.logFiles);
|
||||||
|
const mpvLogPath = (0, log_files_1.isLogFileEnabled)('mpv')
|
||||||
|
? process.env.SUBMINER_MPV_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('mpv')
|
||||||
|
: '';
|
||||||
|
if (mpvLogPath) {
|
||||||
|
(0, log_files_1.pruneLogDirectoryForPath)(mpvLogPath, configuredMpvLaunch.logRotation);
|
||||||
|
}
|
||||||
|
const result = await (0, windows_mpv_launch_1.launchWindowsMpv)((0, main_entry_runtime_1.normalizeLaunchMpvTargets)(process.argv), (0, windows_mpv_launch_1.createWindowsMpvLaunchDeps)({
|
||||||
|
getEnv: (name) => process.env[name],
|
||||||
|
isAppControlServerAvailable: () => (0, app_control_client_1.isAppControlServerAvailable)({
|
||||||
|
configDir: userDataPath,
|
||||||
|
timeoutMs: 350,
|
||||||
|
}),
|
||||||
|
sendAppControlCommand: (argv) => (0, app_control_client_1.sendAppControlCommand)(argv, {
|
||||||
|
configDir: userDataPath,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
}),
|
||||||
|
showError: (title, content) => {
|
||||||
|
electron_1.dialog.showErrorBox(title, content);
|
||||||
|
},
|
||||||
|
logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation),
|
||||||
|
}), [...extraArgs, ...(0, mpv_logging_args_1.buildMpvLoggingArgs)(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)], process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), configuredMpvLaunch.executablePath, configuredMpvLaunch.launchMode, createWindowsRuntimePluginPolicy(), configuredMpvLaunch.pluginRuntimeConfig);
|
||||||
|
electron_1.app.exit(result.ok ? 0 : 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((0, main_entry_runtime_1.shouldHandleStatsDaemonCommandAtEntry)(process.argv, process.env)) {
|
||||||
|
await electron_1.app.whenReady();
|
||||||
|
const exitCode = await (0, stats_daemon_entry_1.runStatsDaemonControlFromProcess)(electron_1.app.getPath('userData'));
|
||||||
|
electron_1.app.exit(exitCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await forwardStartupArgvViaAppControlIfAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((0, main_entry_runtime_1.shouldDetachBackgroundLaunch)(process.argv, process.env)) {
|
||||||
|
const childArgs = (0, main_entry_runtime_1.hasTransportedStartupArgs)(process.env) ? [] : process.argv.slice(1);
|
||||||
|
const child = (0, node_child_process_1.spawn)(process.execPath, childArgs, {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: (0, main_entry_runtime_1.sanitizeBackgroundEnv)(process.env),
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
process.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startMainProcess();
|
||||||
|
}
|
||||||
|
void runEntryProcess().catch((error) => {
|
||||||
|
console.error('SubMiner app-control handoff failed:', error);
|
||||||
|
startMainProcess();
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=main-entry.js.map
|
||||||
+3
-3
File diff suppressed because one or more lines are too long
+26
-134
@@ -1,160 +1,51 @@
|
|||||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||||
|
|
||||||
|
<!-- prerelease-base-version: 0.17.0 -->
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections.
|
|
||||||
- Includes click-to-learn keybinding controls, an AnkiConnect deck dropdown that auto-fills from Yomitan's current mining deck, and AnkiConnect-backed deck, field, and note-type pickers.
|
|
||||||
- Live-saves changes for subtitle CSS declarations, stats keys, logging level, Anki field mappings, sentence card model, and other annotation and runtime options; search narrows across all categories including multi-word terms. AI and translation settings remain config-file only.
|
|
||||||
|
|
||||||
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification and configurable update notifications.
|
|
||||||
- The `subminer` launcher and Linux rofi theme update automatically alongside the app.
|
|
||||||
- Set `updates.channel` to `"prerelease"` to receive beta and RC builds.
|
|
||||||
|
|
||||||
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows.
|
|
||||||
- Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
|
|
||||||
- Setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths. An Open SubMiner Settings button is included on completion; the standalone setup app quits after finishing.
|
|
||||||
|
|
||||||
- **Character Portraits:** Character-name subtitle matches can now show optional inline AniList character portraits.
|
|
||||||
- Manual AniList title overrides are scoped per media directory so separate season folders keep independent character dictionary selections.
|
|
||||||
|
|
||||||
- **Log Export:** Sanitized log ZIP archives can be exported from the tray menu or by running `subminer logs -e`, with home-directory usernames redacted from the exported contents.
|
|
||||||
|
|
||||||
- **Logging Configuration:** SubMiner's logging level is now forwarded into launcher-started and Windows shortcut-started mpv sessions, controlling mpv log verbosity and plugin script logging.
|
|
||||||
- The new `logging.rotation` config sets daily log retention (default 7 days). `logging.files` toggles let you enable or disable per-component log files; mpv logs are off by default unless explicitly enabled.
|
|
||||||
|
|
||||||
- **Yomitan Popup Visibility:** The new `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan lookup popup is open.
|
|
||||||
|
|
||||||
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed app version. The new `mpv.profile` config option passes an mpv profile to SubMiner-managed mpv launches, and bundled mpv plugin startup options are now configurable from SubMiner config.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`; sidebar appearance uses `subtitleSidebar.css`.
|
- **Subtitle Delay Shortcuts:** Overlay subtitle delay controls now match mpv's native defaults.
|
||||||
- Default font stack updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`; default text shadow is stronger, JLPT underlines are thicker, and the frequency `topX` threshold defaults to `10000`.
|
- `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show the current delay on the OSD.
|
||||||
- Existing configs are migrated automatically: legacy appearance options and hover token colors fold into `subtitleStyle.css`, and user config files are preserved.
|
|
||||||
|
|
||||||
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings.
|
|
||||||
- N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly.
|
|
||||||
|
|
||||||
- **Character Dictionary:** Entries are now scoped to the current AniList media and generate Japanese name aliases only, so raw romanized or English aliases no longer appear as separate results.
|
|
||||||
- A new `Ctrl/Cmd+D` manager modal lets you remove, reorder, or override loaded dictionary entries.
|
|
||||||
- The in-app AniList title selector now waits for an explicit search rather than triggering automatically; the search box is prefilled from the current filename guess.
|
|
||||||
|
|
||||||
- **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages and non-AppImage launches fall back to the GitHub-asset flow.
|
|
||||||
|
|
||||||
- **Subsync:** The subtitle sync dialog now always opens the manual picker; the `subsync.defaultMode` config option has been removed.
|
|
||||||
|
|
||||||
- **Jellyfin Setup:** The server presets dropdown is replaced by a single editable server URL field.
|
|
||||||
|
|
||||||
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
|
|
||||||
|
|
||||||
- **Runtime:** The bundled Electron runtime is updated from 39.8.6 to 42.2.0.
|
|
||||||
|
|
||||||
- **Subtitle Delay Shortcuts:** Default overlay subtitle delay and step bindings now match mpv's own defaults.
|
|
||||||
- `z`, `Z`, and `x` adjust sub-delay; `Ctrl+Shift+Left/Right` runs native sub-step and shows the current delay on the OSD.
|
|
||||||
- The previous SubMiner-only adjacent-cue delay action has been removed.
|
- The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||||
|
|
||||||
- **Update Notifications:** New installs default to overlay-only update notifications instead of overlay plus system notifications.
|
- **Update Notifications:** New installs now default to overlay-only update notifications instead of overlay plus system notifications.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **macOS Overlay:** Significantly improved overlay focus and stability across a range of scenarios.
|
- **Anki Card Enrichment:** Fixed two issues where card fields were not populated correctly after mining.
|
||||||
- The overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; and opens over fullscreen mpv without switching Spaces.
|
- Highlight Word now bolds the mined word in Kiku sentence and sentence-furigana fields even when the source Yomitan sentence has no existing bold markup.
|
||||||
- Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The compiled mpv window helper is now correctly bundled, preventing the overlay from falling back to a slower startup path on first launch.
|
- Lapis and Kiku word cards enriched through SubMiner now include the word-and-sentence marker, restoring sentence context on the card front.
|
||||||
- Yomitan popup focus is restored after card mining or popup reload; clicking transparent overlay space correctly closes the popup and returns passthrough to mpv without a hide/reappear cycle.
|
|
||||||
|
|
||||||
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player.
|
- **Windows Overlay:** Fixed shaky hover and click behavior on the subtitle bar when a video attaches to an already-running SubMiner instance.
|
||||||
- The overlay stays stacked above mpv after click-to-focus events and is suspended while the in-player stats window is open.
|
|
||||||
- Settings windows (SubMiner and Yomitan) now open above the subtitle overlay; the overlay hides immediately when the character dictionary modal opens, including while AniList lookup is in progress.
|
|
||||||
- Auto-paused startup is more reliable: the overlay stays interactive during the first measurement gap, startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv subtitle reads are resolved before synthetic warm readiness can resume playback.
|
|
||||||
|
|
||||||
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
- **Windows Anki & Media:** Fixed two issues affecting Windows users running SubMiner in background-launch mode.
|
||||||
- The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct Linux bounds reapplication and user-paused playback preserved through readiness gates.
|
- Known-word cache refreshes no longer fail when no deck is configured.
|
||||||
- The visible overlay remains active when mpv advances to the next playlist item, even when the next episode loads after the warm transition delay.
|
- Audio and image clipping now works correctly by recreating missing FFmpeg temp directories before processing.
|
||||||
|
|
||||||
- **Jellyfin Playback:** Resolved a wide range of discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, and duplicate ready signals no longer re-show the overlay.
|
- **Windows Character Dictionary:** The character dictionary auto-sync now correctly falls back to mpv's current video path on Windows when app media state is not yet ready.
|
||||||
- Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track.
|
|
||||||
- Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
|
|
||||||
|
|
||||||
- **Jellyfin Subtitles:** Improved subtitle timing by preferring default embedded streams over external sidecars, stripping Jellyfin's server-selected stream from playback URLs, suppressing mpv auto-selection while SubMiner stages managed tracks, and automatically correcting Japanese-vs-English cue timeline offsets.
|
- **Linux Support Assets:** Linux updates now create and refresh both managed support assets: the launcher runtime plugin copy and the rofi theme.
|
||||||
- Per-stream subtitle delay shifts are restored on load. Track selection now tolerates transient `track-list` read failures and numeric string track IDs on Linux.
|
- First playback on a fresh Linux install auto-installs those bundled assets before mpv starts if either one is missing.
|
||||||
|
- Asset refreshes leave unrelated SubMiner data directories untouched and stage plugin copies before replacing the live runtime plugin.
|
||||||
|
|
||||||
- **Jellyfin Overlay:** The visible subtitle overlay now shows automatically during Jellyfin playback so `subtitleStyle` appearance applies, and the bundled mpv plugin is injected when SubMiner auto-launches mpv so mpv-side keybindings work without overlay focus.
|
- **Linux Visible Overlay Startup:** Auto-paused visible overlay startup stays fully interactive during the first measurement gap.
|
||||||
- The `y-t` overlay toggle is reliable and remains sticky across stream redirects.
|
- Startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv subtitle reads refresh parsed cues before warm readiness resumes playback.
|
||||||
- Passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv.
|
|
||||||
|
|
||||||
- **Jellyfin Remote Progress:** Fixed progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows.
|
- **Playlist Transitions:** The visible overlay stays active while mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||||
- Play and Resume are now distinct: Play starts from the beginning while Resume starts at the saved position.
|
|
||||||
- Final progress reports use SubMiner's last known position when mpv resets during stop.
|
|
||||||
|
|
||||||
- **Jellyfin Identity:** Cast device identity is now derived from the OS hostname. Multiple SubMiner installs no longer share the same remote-session identity.
|
- **macOS Yomitan Popup Focus:** Yomitan popup focus is restored after card mining or popup reload.
|
||||||
|
- Clicking transparent overlay space now closes the popup and returns passthrough to mpv without a hide/reappear cycle.
|
||||||
|
|
||||||
- **Jellyfin Tray:** The discovery tray checkbox stays in sync on Linux after tray, CLI, or startup remote-session changes. Stale discovery sessions restart automatically when the server no longer lists the SubMiner cast target.
|
- **Stats AniList Search:** Manual AniList linking from the stats page now strips generated `Season N` suffixes before searching, so the base anime title is used.
|
||||||
|
|
||||||
- **Jellyfin Setup:** Fixed the Windows login flow with an IPC bridge and immediate progress feedback; unreachable servers time out with an inline error instead of hanging.
|
- **Desktop Notifications:** System notifications now show the SubMiner app icon when no custom notification image is provided.
|
||||||
|
|
||||||
- **AniList Progress:** Threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold.
|
- **Release Notes:** GitHub release `What's Changed` and `New Contributors` attribution sections are preserved when CI regenerates release notes from committed changelog output.
|
||||||
- Season-specific results are preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching status.
|
|
||||||
- Repeated missing-token checks no longer exhaust AniList retry attempts or create duplicate dead-letter entries for the same episode.
|
|
||||||
- Manual AniList linking from the stats anime page now strips generated `Season N` suffixes from automatic searches so results match the base title correctly.
|
|
||||||
|
|
||||||
- **Anki:** Sentence-audio padding is now opt-in by default; animated AVIF freeze-frame duration is correctly aligned to word audio length without double-counting padding.
|
|
||||||
- Multi-line sentence mining stays aligned for repeated subtitle text; Kiku duplicate-card detection and merge flow are fixed; clipboard card updates from YouTube use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving.
|
|
||||||
- Known-word cache append is fixed when no default Anki mining deck is configured but multiple known-word deck field mappings are present.
|
|
||||||
|
|
||||||
- **YouTube:** Primary subtitles are downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit.
|
|
||||||
- False load-failure notifications are suppressed. Launcher-managed playback creates the tray icon when attaching to an already-running process, and app-owned playback no longer lets the mpv plugin start a second SubMiner instance.
|
|
||||||
|
|
||||||
- **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names; cached snapshots are regenerated to include them.
|
|
||||||
- Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests. Manager keyboard shortcuts are correctly forwarded to the mpv plugin.
|
|
||||||
|
|
||||||
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground.
|
|
||||||
- Builds that cannot apply native updates show a manual-install message instead of a restart prompt. Windows retains the native NSIS update path while routing updater HTTP through the main process.
|
|
||||||
- Linux updates now correctly create and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; both support assets are auto-installed from the bundled app on first launch if either is missing.
|
|
||||||
|
|
||||||
- **Setup - macOS:** First-run setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths.
|
|
||||||
- `subminer app --setup` opens the setup flow even when SubMiner is already running. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
|
|
||||||
|
|
||||||
- **Tray App:** Fixed several lifecycle issues: the tray stays running when Yomitan settings are closed; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable.
|
|
||||||
- Settings loading no longer blocks other tray actions; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized; the session help modal closes correctly without mpv running.
|
|
||||||
- On Windows, "Open SubMiner Setup" now correctly opens the setup window after first-run setup is complete. System notifications now correctly show the SubMiner app icon when no custom notification image is provided.
|
|
||||||
|
|
||||||
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused until subtitle priming and tokenization readiness complete.
|
|
||||||
- `subminer settings` on macOS no longer emits Electron menu diagnostics and exits cleanly when the window is closed. Linux first-run launcher installs build with a valid Bun shebang; `subminer app` on Linux returns control to the terminal immediately.
|
|
||||||
- On Windows, managed mpv launches from a background instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
|
|
||||||
|
|
||||||
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered. Annotations are corrected for Yomitan single-token compounds with internal particles like `目の前`.
|
|
||||||
|
|
||||||
- **Subtitle Annotation Prefetch:** Cached annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
|
|
||||||
|
|
||||||
- **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so SubMiner shortcuts also work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows.
|
|
||||||
|
|
||||||
- **Stats:** In-player stats layering is fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. Jellyfin playback stats are grouped under item metadata so watched episodes merge with matching local library titles and display clean names.
|
|
||||||
|
|
||||||
- **Sidebar:** Yomitan lookup popups opened from the subtitle sidebar now correctly pause playback when popup auto-pause is enabled. Mined cards use audio and images from the clicked subtitle line rather than the current primary line.
|
|
||||||
|
|
||||||
- **Controller:** Config and debug shortcuts stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge; remaps are saved per controller profile, and individual bindings can be reset to their defaults.
|
|
||||||
|
|
||||||
- **Discord Rich Presence:** Presence no longer falls back to Jellyfin stream URLs; Jellyfin playback titles are primed before loading tokenized streams so presence shows the show/episode title.
|
|
||||||
|
|
||||||
- **WebSocket:** The regular subtitle WebSocket now sends plain text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket.
|
|
||||||
|
|
||||||
- **Windows Startup:** Fatal startup errors now show a native error dialog and write details to the app log instead of exiting silently.
|
|
||||||
|
|
||||||
- **Yomitan:** Fixed popups not opening when overlay startup races the Yomitan extension load.
|
|
||||||
|
|
||||||
- **Subtitle Sync Modal:** Fixed a macOS issue where the modal would flash and disappear on the first attempt, or leave stale state after syncing.
|
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
- **Versioned Docs:** Stable docs are now published at the site root with current development docs under `/main/`.
|
- **Linux Update Flow:** Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal playback auto-installs those managed support assets if either one is missing.
|
||||||
- Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests paths incorrectly, and local dev version routes serve warmed archive files instead of redirecting to production.
|
|
||||||
|
|
||||||
- **Configuration Reference:** All previously undocumented config options are now covered, including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options. Updated known-word cache docs and examples to recommend expression/word fields.
|
|
||||||
|
|
||||||
- **Architecture Docs:** Added a Playback Startup Flow diagram and a Runtime Sockets section and diagram to the IPC + Runtime Contracts page, with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
|
|
||||||
|
|
||||||
- **Linux Update Flow:** Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets if either one is missing.
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
@@ -163,6 +54,7 @@
|
|||||||
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
|
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
|
||||||
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
|
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
|
||||||
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
|
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
|
||||||
|
- Fix Windows Anki startup and overlay regressions by @ksyasuda in #128
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
## Highlights
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Subtitle Delay Keybindings:** Overlay subtitle delay controls now match mpv's native bindings.
|
||||||
|
- `z`, `Z`, and `x` adjust subtitle delay; `Ctrl+Shift+Left/Right` step to the adjacent subtitle and show the current delay on the OSD.
|
||||||
|
- Removes the old SubMiner-specific adjacent-cue delay action in favor of mpv's built-in `sub-step`.
|
||||||
|
|
||||||
|
- **Update Notifications:** New installs now default to overlay-only update notifications instead of also sending a system notification.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Anki — Highlight Word:** The mined word is now correctly bolded in Kiku sentence and sentence-furigana fields even when the source Yomitan sentence did not already contain bold markup.
|
||||||
|
|
||||||
|
- **Anki — Lapis/Kiku Word Cards:** Word cards enriched through SubMiner now correctly include the word-and-sentence marker, restoring sentence context on the card front.
|
||||||
|
|
||||||
|
- **Anki — Windows:** Fixed two issues that surfaced after background app launches on Windows.
|
||||||
|
- Audio clip and image generation now works correctly by recreating missing FFmpeg temp directories before export.
|
||||||
|
- Known-word cache refreshes no longer fail when no deck is configured.
|
||||||
|
|
||||||
|
- **Desktop Notifications:** Restored the SubMiner app icon on system notifications that do not supply their own image.
|
||||||
|
|
||||||
|
- **Dictionary — Windows:** The character dictionary auto-sync on `SubMiner mpv` shortcut launches can now fall back to mpv's current video path when app media state is not yet ready.
|
||||||
|
|
||||||
|
- **Overlay — macOS Yomitan Popup:** Fixed focus and dismiss behavior for the Yomitan popup on macOS.
|
||||||
|
- Popup focus is correctly restored after mining a card or reloading the popup.
|
||||||
|
- Clicking on transparent overlay space now properly closes the popup and passes the click through to mpv, with no hide/reappear cycle.
|
||||||
|
|
||||||
|
- **Overlay — Linux Startup:** Fixed several edge cases that could leave the overlay unresponsive or drop subtitles at startup when auto-pause was active.
|
||||||
|
- The overlay stays interactive during the initial render measurement gap.
|
||||||
|
- Subtitles paint as plain text immediately on cache misses, before tokenization finishes.
|
||||||
|
- Temporarily empty subtitle state is now re-parsed correctly before warm readiness resumes playback.
|
||||||
|
|
||||||
|
- **Overlay — Playlist Advance:** The visible overlay now stays interactive when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||||
|
|
||||||
|
- **Overlay — Windows:** Fixed shaky subtitle-bar hover and click behavior when a video connects to an already-running background SubMiner instance.
|
||||||
|
|
||||||
|
- **Stats — AniList Search:** Manual AniList linking from the stats anime page now searches only the anime title, dropping any generated "Season N" suffix that was causing failed lookups.
|
||||||
|
|
||||||
|
- **Updates — Linux:** Improved Linux update reliability for managed support assets.
|
||||||
|
- Updates now correctly install and refresh both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates.
|
||||||
|
- Support-asset refreshes no longer touch unrelated SubMiner data directories, and plugin copies are staged safely before replacing the live runtime plugin.
|
||||||
|
- Fresh installs now auto-install the managed runtime plugin and rofi theme from the bundled app on first launcher playback if either asset is missing.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- **Linux Updates:** Documented how Linux update flows manage the launcher runtime plugin and rofi theme, and that the first launcher playback auto-installs any missing managed support assets from the bundled app.
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120
|
||||||
|
- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121
|
||||||
|
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
|
||||||
|
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
|
||||||
|
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
|
||||||
|
- Fix Windows Anki startup and overlay regressions by @ksyasuda in #128
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
See the README and docs/installation guide for full setup steps.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- Linux: `SubMiner.AppImage`
|
||||||
|
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||||
|
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||||
|
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||||
|
|
||||||
|
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||||
@@ -605,6 +605,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
|
|||||||
|
|
||||||
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||||
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
|
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
|
||||||
|
assert.match(prereleaseNotes, /<!-- prerelease-base-version: 0\.11\.3 -->/);
|
||||||
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
||||||
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
||||||
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||||
@@ -620,6 +621,8 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
|
|||||||
const existingNotes = [
|
const existingNotes = [
|
||||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
'',
|
'',
|
||||||
|
'<!-- prerelease-base-version: 0.11.3 -->',
|
||||||
|
'',
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
'### Added',
|
'### Added',
|
||||||
'- Overlay: Previous beta entry.',
|
'- Overlay: Previous beta entry.',
|
||||||
@@ -679,6 +682,61 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an older release line', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-ignore-unmarked-old-notes');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
const existingNotes = [
|
||||||
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
|
'',
|
||||||
|
'## Highlights',
|
||||||
|
'### Added',
|
||||||
|
'- Settings Window: Previous release line entry.',
|
||||||
|
'',
|
||||||
|
'## Installation',
|
||||||
|
'',
|
||||||
|
'See the README and docs/installation guide for full setup steps.',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.17.0-beta.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
[
|
||||||
|
'type: changed',
|
||||||
|
'area: overlay',
|
||||||
|
'',
|
||||||
|
'- Replaced subtitle delay actions with native mpv keybindings.',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stub = defaultStubClaude();
|
||||||
|
const outputPath = writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.17.0-beta.1',
|
||||||
|
deps: { runClaude: stub.runClaude },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
|
||||||
|
assert.doesNotMatch(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
|
||||||
|
assert.doesNotMatch(stub.calls[0]!.input, /Settings Window: Previous release line entry/);
|
||||||
|
|
||||||
|
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||||
|
assert.match(prereleaseNotes, /### Changed\n- Polished: changed entry\./);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => {
|
test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => {
|
||||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
const workspace = createWorkspace('prerelease-net-outcome-prompt');
|
const workspace = createWorkspace('prerelease-net-outcome-prompt');
|
||||||
@@ -686,6 +744,8 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
|
|||||||
const existingNotes = [
|
const existingNotes = [
|
||||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
'',
|
'',
|
||||||
|
'<!-- prerelease-base-version: 0.12.0 -->',
|
||||||
|
'',
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
'### Added',
|
'### Added',
|
||||||
'- Config Window: Previous beta entry.',
|
'- Config Window: Previous beta entry.',
|
||||||
|
|||||||
@@ -93,6 +93,40 @@ function isSupportedPrereleaseVersion(version: string): boolean {
|
|||||||
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
|
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePrereleaseBaseVersion(version: string): string {
|
||||||
|
const match = /^(\d+\.\d+\.\d+)-(?:beta|rc)\.\d+$/u.exec(normalizeVersion(version));
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match[1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPrereleaseBaseVersionMarker(version: string): string {
|
||||||
|
return `<!-- prerelease-base-version: ${resolvePrereleaseBaseVersion(version)} -->`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPrereleaseBaseVersionMarker(notes: string): string | null {
|
||||||
|
return (
|
||||||
|
/<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPrereleaseMetadata(notes: string): string {
|
||||||
|
return notes
|
||||||
|
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
|
||||||
|
const existingBaseVersion = extractPrereleaseBaseVersionMarker(notes);
|
||||||
|
if (existingBaseVersion !== resolvePrereleaseBaseVersion(version)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return stripPrereleaseMetadata(notes);
|
||||||
|
}
|
||||||
|
|
||||||
function verifyRequestedVersionMatchesPackageVersion(
|
function verifyRequestedVersionMatchesPackageVersion(
|
||||||
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
||||||
): void {
|
): void {
|
||||||
@@ -669,13 +703,16 @@ function renderReleaseNotes(
|
|||||||
disclaimer?: string;
|
disclaimer?: string;
|
||||||
contributions?: Contribution[];
|
contributions?: Contribution[];
|
||||||
contributorSections?: string[];
|
contributorSections?: string[];
|
||||||
|
metadata?: string[];
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||||
|
const metadata = options?.metadata?.length ? [...options.metadata, ''] : [];
|
||||||
const contributorSections =
|
const contributorSections =
|
||||||
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
|
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
|
||||||
return [
|
return [
|
||||||
...prefix,
|
...prefix,
|
||||||
|
...metadata,
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
changes,
|
changes,
|
||||||
'',
|
'',
|
||||||
@@ -705,6 +742,7 @@ function writeReleaseNotesFile(
|
|||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
contributions?: Contribution[];
|
contributions?: Contribution[];
|
||||||
contributorSections?: string[];
|
contributorSections?: string[];
|
||||||
|
metadata?: string[];
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||||
@@ -1038,7 +1076,7 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
|||||||
|
|
||||||
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
|
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
|
||||||
const existingReleaseNotes = existsSync(prereleaseNotesPath)
|
const existingReleaseNotes = existsSync(prereleaseNotesPath)
|
||||||
? readFileSync(prereleaseNotesPath, 'utf8')
|
? resolveReusablePrereleaseNotes(readFileSync(prereleaseNotesPath, 'utf8'), version)
|
||||||
: undefined;
|
: undefined;
|
||||||
const changes = polishFragmentsWithClaude(fragments, {
|
const changes = polishFragmentsWithClaude(fragments, {
|
||||||
mode: 'release-notes',
|
mode: 'release-notes',
|
||||||
@@ -1052,6 +1090,7 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
|||||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
outputPath: PRERELEASE_NOTES_PATH,
|
outputPath: PRERELEASE_NOTES_PATH,
|
||||||
contributions,
|
contributions,
|
||||||
|
metadata: [renderPrereleaseBaseVersionMarker(version)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function createIntegrationTestContext(
|
|||||||
knownWordsScope: string;
|
knownWordsScope: string;
|
||||||
knownWordsLastRefreshedAtMs: number;
|
knownWordsLastRefreshedAtMs: number;
|
||||||
};
|
};
|
||||||
privateState.knownWordsScope = 'is:note';
|
privateState.knownWordsScope = 'all';
|
||||||
privateState.knownWordsLastRefreshedAtMs = Date.now();
|
privateState.knownWordsLastRefreshedAtMs = Date.now();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -324,6 +324,119 @@ test('AnkiIntegration resolves merged-away note ids to the kept note id', () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function processSentenceWithConfig(
|
||||||
|
config: Partial<AnkiConnectConfig>,
|
||||||
|
mpvSentence: string,
|
||||||
|
noteFields: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
|
||||||
|
return (
|
||||||
|
integration as unknown as {
|
||||||
|
processSentence: (sentence: string, fields: Record<string, string>) => string;
|
||||||
|
}
|
||||||
|
).processSentence(mpvSentence, noteFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processSentenceFuriganaWithConfig(
|
||||||
|
config: Partial<AnkiConnectConfig>,
|
||||||
|
sentenceFurigana: string,
|
||||||
|
noteFields: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
|
||||||
|
return (
|
||||||
|
integration as unknown as {
|
||||||
|
processSentenceFurigana: (sentence: string, fields: Record<string, string>) => string;
|
||||||
|
}
|
||||||
|
).processSentenceFurigana(sentenceFurigana, noteFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('AnkiIntegration highlights mined word from expression field when sentence has no bold marker', () => {
|
||||||
|
const processed = processSentenceWithConfig(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
highlightWord: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'先日 貴様らが潜入した キールダンジョンから―',
|
||||||
|
{
|
||||||
|
expression: '潜入',
|
||||||
|
sentence: '先日 貴様らが潜入した キールダンジョンから―',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(processed, '先日 貴様らが<b>潜入</b>した キールダンジョンから―');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration keeps existing Yomitan bold target when present', () => {
|
||||||
|
const processed = processSentenceWithConfig(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
highlightWord: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'先日 貴様らが潜入した キールダンジョンから―',
|
||||||
|
{
|
||||||
|
expression: '潜入',
|
||||||
|
sentence: '<b>潜入した</b>',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(processed, '先日 貴様らが<b>潜入した</b> キールダンジョンから―');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => {
|
||||||
|
const processed = processSentenceWithConfig(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
highlightWord: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'先日 貴様らが潜入した キールダンジョンから―',
|
||||||
|
{
|
||||||
|
expression: '潜入',
|
||||||
|
sentence: '<b>潜入</b>',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration highlights mined word in sentence furigana field', () => {
|
||||||
|
const processed = processSentenceFuriganaWithConfig(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
highlightWord: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span><span class="term">を</span>',
|
||||||
|
{
|
||||||
|
expression: '特技',
|
||||||
|
sentence: '不思議な特技を',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
processed,
|
||||||
|
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><b><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span></b><span class="term">を</span>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
|
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
|
|||||||
+114
-7
@@ -70,7 +70,7 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio';
|
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
@@ -78,6 +78,66 @@ function trimToNonEmptyString(value: unknown): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripRubyReadingText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/<rt\b[^>]*>[\s\S]*?<\/rt>/gi, '')
|
||||||
|
.replace(/<rp\b[^>]*>[\s\S]*?<\/rp>/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtmlTags(value: string): string {
|
||||||
|
return value.replace(/<[^>]+>/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleFuriganaText(value: string): string {
|
||||||
|
return stripHtmlTags(stripRubyReadingText(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function boldMatchingFuriganaTerms(sentenceFurigana: string, highlightedText: string): string {
|
||||||
|
if (!sentenceFurigana || !highlightedText || /<b\b/i.test(sentenceFurigana)) {
|
||||||
|
return sentenceFurigana;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spanRegex = /<span\b[^>]*>[\s\S]*?<\/span>/gi;
|
||||||
|
const spans: Array<{ start: number; end: number; visibleStart: number; visibleEnd: number }> = [];
|
||||||
|
let visibleSentence = '';
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = spanRegex.exec(sentenceFurigana)) !== null) {
|
||||||
|
const visibleStart = visibleSentence.length;
|
||||||
|
visibleSentence += getVisibleFuriganaText(match[0] || '');
|
||||||
|
spans.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
visibleStart,
|
||||||
|
visibleEnd: visibleSentence.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spans.length === 0) {
|
||||||
|
return sentenceFurigana.replace(highlightedText, `<b>${highlightedText}</b>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightStart = visibleSentence.indexOf(highlightedText);
|
||||||
|
if (highlightStart === -1) {
|
||||||
|
return sentenceFurigana;
|
||||||
|
}
|
||||||
|
const highlightEnd = highlightStart + highlightedText.length;
|
||||||
|
const matchingSpans = spans.filter(
|
||||||
|
(span) => span.visibleEnd > highlightStart && span.visibleStart < highlightEnd,
|
||||||
|
);
|
||||||
|
if (matchingSpans.length === 0) {
|
||||||
|
return sentenceFurigana;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sentenceFurigana;
|
||||||
|
for (const span of [...matchingSpans].reverse()) {
|
||||||
|
result = `${result.slice(0, span.start)}<b>${result.slice(
|
||||||
|
span.start,
|
||||||
|
span.end,
|
||||||
|
)}</b>${result.slice(span.end)}`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function decodeURIComponentSafe(value: string): string {
|
function decodeURIComponentSafe(value: string): string {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(value);
|
return decodeURIComponent(value);
|
||||||
@@ -461,6 +521,10 @@ export class AnkiIntegration {
|
|||||||
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
|
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
|
||||||
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
|
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
|
||||||
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
|
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
|
||||||
|
processSentenceFurigana: (sentenceFurigana, noteFields) =>
|
||||||
|
this.processSentenceFurigana(sentenceFurigana, noteFields),
|
||||||
|
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
|
||||||
|
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
|
||||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||||
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||||
@@ -677,20 +741,25 @@ export class AnkiIntegration {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSentenceHighlightText(noteFields: Record<string, string>): string {
|
||||||
|
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
|
||||||
|
const existingSentence = noteFields[sentenceFieldName] || '';
|
||||||
|
return (
|
||||||
|
existingSentence.match(/<b>(.*?)<\/b>/)?.[1] ||
|
||||||
|
getPreferredWordValueFromExtractedFields(noteFields, this.config).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private processSentence(mpvSentence: string, noteFields: Record<string, string>): string {
|
private processSentence(mpvSentence: string, noteFields: Record<string, string>): string {
|
||||||
if (this.config.behavior?.highlightWord === false) {
|
if (this.config.behavior?.highlightWord === false) {
|
||||||
return mpvSentence;
|
return mpvSentence;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
|
const highlightedText = this.getSentenceHighlightText(noteFields);
|
||||||
const existingSentence = noteFields[sentenceFieldName] || '';
|
if (!highlightedText) {
|
||||||
|
|
||||||
const highlightMatch = existingSentence.match(/<b>(.*?)<\/b>/);
|
|
||||||
if (!highlightMatch || !highlightMatch[1]) {
|
|
||||||
return mpvSentence;
|
return mpvSentence;
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightedText = highlightMatch[1];
|
|
||||||
const index = mpvSentence.indexOf(highlightedText);
|
const index = mpvSentence.indexOf(highlightedText);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -702,6 +771,20 @@ export class AnkiIntegration {
|
|||||||
return `${prefix}<b>${highlightedText}</b>${suffix}`;
|
return `${prefix}<b>${highlightedText}</b>${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private processSentenceFurigana(
|
||||||
|
sentenceFurigana: string,
|
||||||
|
noteFields: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
if (this.config.behavior?.highlightWord === false) {
|
||||||
|
return sentenceFurigana;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightedText = this.getSentenceHighlightText(noteFields);
|
||||||
|
return highlightedText
|
||||||
|
? boldMatchingFuriganaTerms(sentenceFurigana, highlightedText)
|
||||||
|
: sentenceFurigana;
|
||||||
|
}
|
||||||
|
|
||||||
private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
|
private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
|
||||||
if (!this.consumeSubtitleMiningContextCallback) {
|
if (!this.consumeSubtitleMiningContextCallback) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1030,6 +1113,30 @@ export class AnkiIntegration {
|
|||||||
): void {
|
): void {
|
||||||
const audioFlagNames = ['IsAudioCard'];
|
const audioFlagNames = ['IsAudioCard'];
|
||||||
|
|
||||||
|
if (cardKind === 'word-and-sentence') {
|
||||||
|
const wordAndSentenceFlag = this.resolveFieldName(
|
||||||
|
availableFieldNames,
|
||||||
|
'IsWordAndSentenceCard',
|
||||||
|
);
|
||||||
|
if (!wordAndSentenceFlag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updatedFields[wordAndSentenceFlag] = 'x';
|
||||||
|
|
||||||
|
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||||
|
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[sentenceFlag] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const audioFlagName of audioFlagNames) {
|
||||||
|
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cardKind === 'sentence') {
|
if (cardKind === 'sentence') {
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||||
if (sentenceFlag) {
|
if (sentenceFlag) {
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import type { AnkiConnectConfig } from '../types/anki';
|
|||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
|
function setWordAndSentenceCardTypeFields(
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
availableFieldNames: string[],
|
||||||
|
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
||||||
|
): void {
|
||||||
|
if (cardKind !== 'word-and-sentence') return;
|
||||||
|
|
||||||
|
const resolveFieldName = (preferredName: string): string | null =>
|
||||||
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
|
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||||
|
if (!wordAndSentenceFlag) return;
|
||||||
|
|
||||||
|
updatedFields[wordAndSentenceFlag] = 'x';
|
||||||
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
service: CardCreationService;
|
service: CardCreationService;
|
||||||
updatedFields: Record<string, string>[];
|
updatedFields: Record<string, string>[];
|
||||||
@@ -142,6 +163,72 @@ test('manual clipboard subtitle update replaces sentence audio without touching
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
|
||||||
|
const { service, updatedFields } = createManualUpdateService({
|
||||||
|
getConfig: () =>
|
||||||
|
({
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
audio: 'ExpressionAudio',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
overwriteAudio: false,
|
||||||
|
overwriteImage: false,
|
||||||
|
},
|
||||||
|
ai: false,
|
||||||
|
}) as AnkiConnectConfig,
|
||||||
|
client: {
|
||||||
|
addNote: async () => 0,
|
||||||
|
addTags: async () => undefined,
|
||||||
|
notesInfo: async () => [
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: '単語' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateNoteFields: async (_noteId, fields) => {
|
||||||
|
updatedFields.push(fields);
|
||||||
|
},
|
||||||
|
storeMediaFile: async () => undefined,
|
||||||
|
findNotes: async () => [42],
|
||||||
|
retrieveMediaFile: async () => '',
|
||||||
|
},
|
||||||
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
|
model: 'Sentence',
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
audioField: 'SentenceAudio',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'disabled',
|
||||||
|
kikuDeleteDuplicateInAuto: false,
|
||||||
|
}),
|
||||||
|
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateLastAddedFromClipboard('字幕');
|
||||||
|
|
||||||
|
assert.equal(updatedFields.length, 1);
|
||||||
|
assert.deepEqual(updatedFields[0], {
|
||||||
|
Sentence: '字幕',
|
||||||
|
IsWordAndSentenceCard: 'x',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
|
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
|
||||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
||||||
client: {
|
client: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AiConfig } from '../types/integrations';
|
|||||||
import { MpvClient } from '../types/runtime';
|
import { MpvClient } from '../types/runtime';
|
||||||
import { resolveSentenceBackText } from './ai';
|
import { resolveSentenceBackText } from './ai';
|
||||||
import { resolveMediaGenerationInputPath } from './media-source';
|
import { resolveMediaGenerationInputPath } from './media-source';
|
||||||
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration.card-creation');
|
const log = createLogger('anki').child('integration.card-creation');
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export interface CardCreationNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio';
|
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||||
|
|
||||||
interface CardCreationClient {
|
interface CardCreationClient {
|
||||||
addNote(
|
addNote(
|
||||||
@@ -219,7 +220,8 @@ export class CardCreationService {
|
|||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||||
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
|
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||||
|
const sentenceField = sentenceCardConfig.sentenceField;
|
||||||
|
|
||||||
const sentence = blocks.join(' ');
|
const sentence = blocks.join(' ');
|
||||||
const updatedFields: Record<string, string> = {};
|
const updatedFields: Record<string, string> = {};
|
||||||
@@ -230,6 +232,13 @@ export class CardCreationService {
|
|||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
|
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||||
|
this.deps.setCardTypeFields(
|
||||||
|
updatedFields,
|
||||||
|
Object.keys(noteInfo.fields),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: 120_000,
|
refreshedAtMs: 120_000,
|
||||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
|
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
@@ -143,7 +143,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: 59_000,
|
refreshedAtMs: 59_000,
|
||||||
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
|
scope: '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
@@ -229,7 +229,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: 1,
|
refreshedAtMs: 1,
|
||||||
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
|
scope: '{"refreshMinutes":1440,"scope":"all","fieldsWord":"Word"}',
|
||||||
words: ['猫', '犬'],
|
words: ['猫', '犬'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
@@ -276,6 +276,36 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager uses empty query when no known-word deck is configured', async () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: {
|
||||||
|
word: 'Word',
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientState.findNotesByQuery.set('', [1]);
|
||||||
|
clientState.notesInfoResult = [
|
||||||
|
{
|
||||||
|
noteId: 1,
|
||||||
|
fields: {
|
||||||
|
Word: { value: '猫' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await manager.refresh(true);
|
||||||
|
|
||||||
|
assert.equal(manager.isKnownWord('猫'), true);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('KnownWordCacheManager skips malformed note info without fields', async () => {
|
test('KnownWordCacheManager skips malformed note info without fields', async () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
fields: {
|
fields: {
|
||||||
@@ -364,7 +394,7 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
|
|||||||
scope: string;
|
scope: string;
|
||||||
words: string[];
|
words: string[];
|
||||||
};
|
};
|
||||||
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}');
|
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
|
||||||
assert.deepEqual(persisted.words, ['猫']);
|
assert.deepEqual(persisted.words, ['猫']);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||||
@@ -568,7 +598,7 @@ test('KnownWordCacheManager reports immediate append cache clears as mutations',
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: Date.now(),
|
refreshedAtMs: Date.now(),
|
||||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":"Expression"}',
|
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":"Expression"}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configuredDeck = trimToNonEmptyString(config.deck);
|
const configuredDeck = trimToNonEmptyString(config.deck);
|
||||||
return configuredDeck ? `deck:${configuredDeck}` : 'is:note';
|
return configuredDeck ? `deck:${configuredDeck}` : 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
||||||
@@ -396,7 +396,7 @@ export class KnownWordCacheManager {
|
|||||||
private buildKnownWordsQuery(): string {
|
private buildKnownWordsQuery(): string {
|
||||||
const decks = this.getKnownWordDecks();
|
const decks = this.getKnownWordDecks();
|
||||||
if (decks.length === 0) {
|
if (decks.length === 0) {
|
||||||
return 'is:note';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decks.length === 1) {
|
if (decks.length === 1) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export interface NoteFieldValueInfo {
|
||||||
|
fields: Record<string, { value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): string | null {
|
||||||
|
const resolvedFieldName = Object.keys(noteInfo.fields).find(
|
||||||
|
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
|
||||||
|
);
|
||||||
|
return resolvedFieldName ? (noteInfo.fields[resolvedFieldName]?.value ?? '') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): boolean {
|
||||||
|
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldMarkWordAndSentenceCard(
|
||||||
|
noteInfo: NoteFieldValueInfo,
|
||||||
|
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
||||||
|
): boolean {
|
||||||
|
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
||||||
|
if (wordAndSentenceValue === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (wordAndSentenceValue.trim().length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,27 @@ import {
|
|||||||
} from './note-update-workflow';
|
} from './note-update-workflow';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
|
||||||
|
function setWordAndSentenceCardTypeFields(
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
availableFieldNames: string[],
|
||||||
|
cardKind: 'word-and-sentence',
|
||||||
|
): void {
|
||||||
|
assert.equal(cardKind, 'word-and-sentence');
|
||||||
|
const resolveFieldName = (preferredName: string): string | null =>
|
||||||
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
|
|
||||||
|
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||||
|
if (!wordAndSentenceFlag) return;
|
||||||
|
|
||||||
|
updatedFields[wordAndSentenceFlag] = 'x';
|
||||||
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createWorkflowHarness() {
|
function createWorkflowHarness() {
|
||||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||||
const notifications: Array<{ noteId: number; label: string | number }> = [];
|
const notifications: Array<{ noteId: number; label: string | number }> = [];
|
||||||
@@ -40,6 +61,7 @@ function createWorkflowHarness() {
|
|||||||
getCurrentSubtitleStart: () => 12.3,
|
getCurrentSubtitleStart: () => 12.3,
|
||||||
getEffectiveSentenceCardConfig: () => ({
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
sentenceField: 'Sentence',
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
kikuEnabled: false,
|
kikuEnabled: false,
|
||||||
kikuFieldGrouping: 'disabled' as const,
|
kikuFieldGrouping: 'disabled' as const,
|
||||||
}),
|
}),
|
||||||
@@ -57,6 +79,7 @@ function createWorkflowHarness() {
|
|||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||||
false,
|
false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
|
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
@@ -102,6 +125,118 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
|
|||||||
assert.equal(harness.notifications.length, 1);
|
assert.equal(harness.notifications.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'tokugi' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
harness.deps.processSentenceFurigana = (sentenceFurigana) =>
|
||||||
|
sentenceFurigana.replace('tokugi', '<b>tokugi</b>');
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence cards', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
IsWordAndSentenceCard: 'x',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: true,
|
||||||
|
kikuEnabled: false,
|
||||||
|
kikuFieldGrouping: 'disabled',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'sentence expression' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: 'x' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () => [];
|
harness.deps.client.notesInfo = async () => [];
|
||||||
@@ -119,6 +254,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
|
|||||||
let notesInfoCallCount = 0;
|
let notesInfoCallCount = 0;
|
||||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
sentenceField: 'Sentence',
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
kikuEnabled: true,
|
kikuEnabled: true,
|
||||||
kikuFieldGrouping: 'auto',
|
kikuFieldGrouping: 'auto',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -35,6 +36,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
getCurrentSubtitleStart: () => number | undefined;
|
getCurrentSubtitleStart: () => number | undefined;
|
||||||
getEffectiveSentenceCardConfig: () => {
|
getEffectiveSentenceCardConfig: () => {
|
||||||
sentenceField: string;
|
sentenceField: string;
|
||||||
|
lapisEnabled: boolean;
|
||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
};
|
};
|
||||||
@@ -58,6 +60,15 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
expression: string,
|
expression: string,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
|
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
|
||||||
|
processSentenceFurigana?: (
|
||||||
|
sentenceFurigana: string,
|
||||||
|
noteFields: Record<string, string>,
|
||||||
|
) => string;
|
||||||
|
setCardTypeFields: (
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
availableFieldNames: string[],
|
||||||
|
cardKind: 'word-and-sentence',
|
||||||
|
) => void;
|
||||||
resolveConfiguredFieldName: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
...preferredNames: (string | undefined)[]
|
...preferredNames: (string | undefined)[]
|
||||||
@@ -189,8 +200,32 @@ export class NoteUpdateWorkflow {
|
|||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
|
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||||
|
this.deps.setCardTypeFields(
|
||||||
|
updatedFields,
|
||||||
|
Object.keys(noteInfo.fields),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
const sentenceFuriganaField = this.deps.resolveConfiguredFieldName(
|
||||||
|
noteInfo,
|
||||||
|
'SentenceFurigana',
|
||||||
|
);
|
||||||
|
const existingSentenceFurigana = sentenceFuriganaField
|
||||||
|
? noteInfo.fields[sentenceFuriganaField]?.value || ''
|
||||||
|
: '';
|
||||||
|
if (sentenceFuriganaField && existingSentenceFurigana && this.deps.processSentenceFurigana) {
|
||||||
|
const processedSentenceFurigana = this.deps.processSentenceFurigana(
|
||||||
|
existingSentenceFurigana,
|
||||||
|
fields,
|
||||||
|
);
|
||||||
|
if (processedSentenceFurigana !== existingSentenceFurigana) {
|
||||||
|
updatedFields[sentenceFuriganaField] = processedSentenceFurigana;
|
||||||
|
updatePerformed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (config.media?.generateAudio) {
|
if (config.media?.generateAudio) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2035,6 +2035,76 @@ Aligned English subtitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card marks Kiku word mining notes as word-and-sentence cards when enabled', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (requests, url) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
addYomitanNote: async () => 777,
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
image: 'Picture',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
isKiku: {
|
||||||
|
enabled: true,
|
||||||
|
fieldGrouping: 'disabled',
|
||||||
|
deleteDuplicateInAuto: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
|
|
||||||
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
|
assert.equal(fields.Sentence, '<b>猫</b>を見た');
|
||||||
|
assert.equal(fields.IsWordAndSentenceCard, 'x');
|
||||||
|
assert.equal(fields.IsSentenceCard, '');
|
||||||
|
assert.equal(fields.IsAudioCard, '');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notesInfoFields: {
|
||||||
|
Expression: { value: '猫' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
Picture: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
|||||||
@@ -204,6 +204,29 @@ function getStatsWordMiningAudioFieldName(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean {
|
||||||
|
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatsWordAndSentenceCardFields(
|
||||||
|
fields: Record<string, string>,
|
||||||
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
|
ankiConfig: AnkiConnectConfig,
|
||||||
|
): void {
|
||||||
|
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
|
||||||
|
|
||||||
|
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
|
||||||
|
if (!wordAndSentenceFlag) return;
|
||||||
|
|
||||||
|
fields[wordAndSentenceFlag] = 'x';
|
||||||
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
fields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getStatsDirectMiningAudioFieldNames(
|
function getStatsDirectMiningAudioFieldNames(
|
||||||
ankiConfig: AnkiConnectConfig,
|
ankiConfig: AnkiConnectConfig,
|
||||||
noteInfo: StatsServerNoteInfo | null,
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
@@ -1299,7 +1322,11 @@ export function createStatsApp(
|
|||||||
|
|
||||||
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||||
let noteInfo: StatsServerNoteInfo | null = null;
|
let noteInfo: StatsServerNoteInfo | null = null;
|
||||||
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
|
if (
|
||||||
|
audioBuffer ||
|
||||||
|
(syncAnimatedImageToWordAudio && generateImage) ||
|
||||||
|
shouldUseStatsLapisKikuCardFields(ankiConfig)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||||
noteInfo = noteInfoResult[0] ?? null;
|
noteInfo = noteInfoResult[0] ?? null;
|
||||||
@@ -1339,6 +1366,7 @@ export function createStatsApp(
|
|||||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
|
||||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||||
|
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||||
|
|||||||
+10
-2
@@ -2244,6 +2244,7 @@ const mediaRuntime = createMediaRuntimeService(
|
|||||||
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
||||||
userDataPath: USER_DATA_PATH,
|
userDataPath: USER_DATA_PATH,
|
||||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||||
|
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
|
||||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||||
@@ -2561,6 +2562,10 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
|||||||
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop();
|
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tickWindowsOverlayPointerInteractionNow(): void {
|
||||||
|
visibleOverlayInteractionRuntime.tickWindowsOverlayPointerInteractionNow();
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleVisibleOverlayBlurRefresh(): void {
|
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||||
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh();
|
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh();
|
||||||
}
|
}
|
||||||
@@ -5408,13 +5413,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
if (!mainWindow || senderWindow !== mainWindow) {
|
if (!mainWindow || senderWindow !== mainWindow) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() === active) {
|
const previousActive =
|
||||||
|
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive();
|
||||||
|
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
|
||||||
|
if (previousActive === active) {
|
||||||
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
|
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
|
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
},
|
},
|
||||||
onOverlayInteractiveHint: (interactive, senderWindow) => {
|
onOverlayInteractiveHint: (interactive, senderWindow) => {
|
||||||
@@ -5614,6 +5621,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
reportOverlayContentBounds: (payload: unknown) => {
|
reportOverlayContentBounds: (payload: unknown) => {
|
||||||
if (overlayContentMeasurementStore.report(payload)) {
|
if (overlayContentMeasurementStore.report(payload)) {
|
||||||
tickLinuxOverlayPointerInteractionNow();
|
tickLinuxOverlayPointerInteractionNow();
|
||||||
|
tickWindowsOverlayPointerInteractionNow();
|
||||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||||
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||||
|
|||||||
@@ -2386,6 +2386,36 @@ test('buildMergedDictionary rebuilds snapshots written with an older format vers
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const mpvPath =
|
||||||
|
'C:\\Videos\\KonoSuba - God’s blessing on this wonderful world!! (2016) - S02E05.mkv';
|
||||||
|
const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = [];
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => null,
|
||||||
|
getCurrentVideoPath: () => mpvPath,
|
||||||
|
getCurrentMediaTitle: () => null,
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async (mediaPath, mediaTitle) => {
|
||||||
|
calls.push({ mediaPath, mediaTitle });
|
||||||
|
return {
|
||||||
|
title: 'KonoSuba - God’s blessing on this wonderful world!!',
|
||||||
|
season: 2,
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await runtime.getManualSelectionSnapshot(undefined, '');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]);
|
||||||
|
assert.equal(snapshot.guessTitle, 'KonoSuba - God’s blessing on this wonderful world!!');
|
||||||
|
assert.equal(snapshot.candidates.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
|
test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ function expandUserPath(input: string): string {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimToNull(input: string | null | undefined): string | null {
|
||||||
|
const trimmed = typeof input === 'string' ? input.trim() : '';
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function isVideoFile(filePath: string): boolean {
|
function isVideoFile(filePath: string): boolean {
|
||||||
return hasVideoExtension(path.extname(filePath));
|
return hasVideoExtension(path.extname(filePath));
|
||||||
}
|
}
|
||||||
@@ -195,8 +200,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
return dictionaryTarget.length > 0
|
return dictionaryTarget.length > 0
|
||||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||||
: {
|
: {
|
||||||
mediaPath: deps.getCurrentMediaPath(),
|
mediaPath:
|
||||||
mediaTitle: deps.getCurrentMediaTitle(),
|
trimToNull(deps.getCurrentMediaPath()) ?? trimToNull(deps.getCurrentVideoPath?.()),
|
||||||
|
mediaTitle: trimToNull(deps.getCurrentMediaTitle()),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export type CharacterDictionaryManualSelectionResult = {
|
|||||||
export interface CharacterDictionaryRuntimeDeps {
|
export interface CharacterDictionaryRuntimeDeps {
|
||||||
userDataPath: string;
|
userDataPath: string;
|
||||||
getCurrentMediaPath: () => string | null;
|
getCurrentMediaPath: () => string | null;
|
||||||
|
getCurrentVideoPath?: () => string | null | undefined;
|
||||||
getCurrentMediaTitle: () => string | null;
|
getCurrentMediaTitle: () => string | null;
|
||||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||||
guessAnilistMediaInfo: (
|
guessAnilistMediaInfo: (
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ test('visible overlay content-ready does not tokenize before first measurement',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
|
test('accepted visible overlay measurement immediately refreshes pointer interaction', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const measurementBlock = source.match(
|
const measurementBlock = source.match(
|
||||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
@@ -317,6 +317,7 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
|||||||
assert.ok(measurementBlock);
|
assert.ok(measurementBlock);
|
||||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||||
|
assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/);
|
||||||
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||||
@@ -324,6 +325,10 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
|||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
||||||
|
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') <
|
||||||
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
|
|||||||
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
|
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
|
||||||
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
|
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
|
||||||
import { hasLiveSeparateWindow } from './settings-window-z-order';
|
import { hasLiveSeparateWindow } from './settings-window-z-order';
|
||||||
|
import { tickWindowsOverlayPointerInteraction } from './windows-overlay-pointer-interaction';
|
||||||
|
|
||||||
export interface VisibleOverlayInteractionRuntimeDeps {
|
export interface VisibleOverlayInteractionRuntimeDeps {
|
||||||
overlayManager: {
|
overlayManager: {
|
||||||
@@ -89,6 +90,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let windowsOverlayPointerInteractionActive = false;
|
||||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||||
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
|
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
|
||||||
@@ -122,6 +124,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
|
|
||||||
function resetVisibleOverlayInputState(): void {
|
function resetVisibleOverlayInputState(): void {
|
||||||
visibleOverlayInteractionActive = false;
|
visibleOverlayInteractionActive = false;
|
||||||
|
windowsOverlayPointerInteractionActive = false;
|
||||||
linuxOverlayInputShapeActive = false;
|
linuxOverlayInputShapeActive = false;
|
||||||
linuxOverlayPointerInteractionStateApplied = false;
|
linuxOverlayPointerInteractionStateApplied = false;
|
||||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||||
@@ -538,6 +541,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
|
|
||||||
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
||||||
maybePollWindowsVisibleOverlayForegroundProcess();
|
maybePollWindowsVisibleOverlayForegroundProcess();
|
||||||
|
tickWindowsOverlayPointerInteractionNow();
|
||||||
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +575,56 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldSuspendWindowsOverlayPointerInteraction(): boolean {
|
||||||
|
return (
|
||||||
|
deps.getModalInputExclusive() ||
|
||||||
|
deps.getStatsOverlayVisible() ||
|
||||||
|
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWindowsOverlayPointerInteractionActive(active: boolean): void {
|
||||||
|
windowsOverlayPointerInteractionActive = active;
|
||||||
|
visibleOverlayInteractionActive = active;
|
||||||
|
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (
|
||||||
|
process.platform !== 'win32' ||
|
||||||
|
!mainWindow ||
|
||||||
|
mainWindow.isDestroyed() ||
|
||||||
|
!mainWindow.isVisible()
|
||||||
|
) {
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
|
} else {
|
||||||
|
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowsOverlayPointerInteractionDeps = {
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
getCursorScreenPoint: () => screen.getCursorScreenPoint(),
|
||||||
|
getSubtitleMeasurement: () => overlayContentMeasurementStore.getLatestByLayer('visible'),
|
||||||
|
shouldSuspend: shouldSuspendWindowsOverlayPointerInteraction,
|
||||||
|
getInteractionActive: () => windowsOverlayPointerInteractionActive,
|
||||||
|
setInteractionActive: updateWindowsOverlayPointerInteractionActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
function tickWindowsOverlayPointerInteractionNow(): void {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!windowsOverlayPointerInteractionActive && visibleOverlayInteractionActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tickWindowsOverlayPointerInteraction(windowsOverlayPointerInteractionDeps);
|
||||||
|
}
|
||||||
|
|
||||||
ensureWindowsVisibleOverlayForegroundPollLoop();
|
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||||
|
|
||||||
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
||||||
@@ -811,10 +865,12 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
updateLinuxOverlayPointerInteractionActive,
|
updateLinuxOverlayPointerInteractionActive,
|
||||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
|
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
|
||||||
requestLinuxOverlayZOrderFollow,
|
requestLinuxOverlayZOrderFollow,
|
||||||
|
tickWindowsOverlayPointerInteractionNow,
|
||||||
tickLinuxOverlayPointerInteractionNow,
|
tickLinuxOverlayPointerInteractionNow,
|
||||||
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||||
setVisibleOverlayInteractionActive: (active: boolean) => {
|
setVisibleOverlayInteractionActive: (active: boolean) => {
|
||||||
visibleOverlayInteractionActive = active;
|
visibleOverlayInteractionActive = active;
|
||||||
|
windowsOverlayPointerInteractionActive = false;
|
||||||
},
|
},
|
||||||
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
|
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
|
||||||
getLastWindowsVisibleOverlayForegroundProcessName: () =>
|
getLastWindowsVisibleOverlayForegroundProcessName: () =>
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
isCursorOverWindowsOverlayInteractiveRect,
|
||||||
|
resolveDesiredWindowsOverlayInteractive,
|
||||||
|
tickWindowsOverlayPointerInteraction,
|
||||||
|
type WindowsOverlayPointerInteractionDeps,
|
||||||
|
} from './windows-overlay-pointer-interaction';
|
||||||
|
import type { OverlayContentMeasurement } from '../../types';
|
||||||
|
|
||||||
|
const BOUNDS = { x: 100, y: 100, width: 1920, height: 1080 };
|
||||||
|
const MEASUREMENT: OverlayContentMeasurement = {
|
||||||
|
layer: 'visible',
|
||||||
|
measuredAtMs: 1,
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
contentRect: { x: 800, y: 900, width: 320, height: 80 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeDeps(overrides: Partial<WindowsOverlayPointerInteractionDeps>): {
|
||||||
|
deps: WindowsOverlayPointerInteractionDeps;
|
||||||
|
state: { active: boolean };
|
||||||
|
} {
|
||||||
|
const state = { active: false };
|
||||||
|
const deps: WindowsOverlayPointerInteractionDeps = {
|
||||||
|
getVisibleOverlayVisible: () => true,
|
||||||
|
getMainWindow: () => ({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
isVisible: () => true,
|
||||||
|
getBounds: () => BOUNDS,
|
||||||
|
}),
|
||||||
|
getCursorScreenPoint: () => ({ x: 1000, y: 1040 }),
|
||||||
|
getSubtitleMeasurement: () => MEASUREMENT,
|
||||||
|
shouldSuspend: () => false,
|
||||||
|
getInteractionActive: () => state.active,
|
||||||
|
setInteractionActive: (active) => {
|
||||||
|
state.active = active;
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { deps, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('isCursorOverWindowsOverlayInteractiveRect hit-tests measured overlay rects', () => {
|
||||||
|
assert.equal(
|
||||||
|
isCursorOverWindowsOverlayInteractiveRect({ x: 1000, y: 1040 }, BOUNDS, MEASUREMENT),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isCursorOverWindowsOverlayInteractiveRect({ x: 500, y: 1040 }, BOUNDS, MEASUREMENT),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCursorOverWindowsOverlayInteractiveRect scales viewport px to window px', () => {
|
||||||
|
const scaled = { ...BOUNDS, width: 3840, height: 2160 };
|
||||||
|
assert.equal(
|
||||||
|
isCursorOverWindowsOverlayInteractiveRect({ x: 1700, y: 1900 }, scaled, MEASUREMENT),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCursorOverWindowsOverlayInteractiveRect uses separate interactive rects', () => {
|
||||||
|
const measurement: OverlayContentMeasurement = {
|
||||||
|
layer: 'visible',
|
||||||
|
measuredAtMs: 1,
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
||||||
|
interactiveRects: [
|
||||||
|
{ x: 700, y: 40, width: 520, height: 80 },
|
||||||
|
{ x: 760, y: 900, width: 400, height: 80 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 300 }, BOUNDS, measurement),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 180 }, BOUNDS, measurement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 1060 }, BOUNDS, measurement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDesiredWindowsOverlayInteractive: interactive over subtitle, passthrough off it', () => {
|
||||||
|
assert.equal(resolveDesiredWindowsOverlayInteractive(makeDeps({}).deps), true);
|
||||||
|
assert.equal(
|
||||||
|
resolveDesiredWindowsOverlayInteractive(
|
||||||
|
makeDeps({ getCursorScreenPoint: () => ({ x: 200, y: 200 }) }).deps,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDesiredWindowsOverlayInteractive returns null while another surface owns input', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveDesiredWindowsOverlayInteractive(makeDeps({ shouldSuspend: () => true }).deps),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveDesiredWindowsOverlayInteractive(makeDeps({ getMainWindow: () => null }).deps),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tickWindowsOverlayPointerInteraction toggles only the fallback-owned state', () => {
|
||||||
|
const calls: boolean[] = [];
|
||||||
|
const { deps, state } = makeDeps({
|
||||||
|
setInteractionActive: (active) => {
|
||||||
|
calls.push(active);
|
||||||
|
state.active = active;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tickWindowsOverlayPointerInteraction(deps);
|
||||||
|
tickWindowsOverlayPointerInteraction(deps);
|
||||||
|
assert.deepEqual(calls, [true]);
|
||||||
|
|
||||||
|
deps.getCursorScreenPoint = () => ({ x: 200, y: 200 });
|
||||||
|
tickWindowsOverlayPointerInteraction(deps);
|
||||||
|
assert.deepEqual(calls, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tickWindowsOverlayPointerInteraction leaves renderer-owned state alone while suspended', () => {
|
||||||
|
const calls: boolean[] = [];
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
getInteractionActive: () => true,
|
||||||
|
shouldSuspend: () => true,
|
||||||
|
setInteractionActive: (active) => calls.push(active),
|
||||||
|
});
|
||||||
|
|
||||||
|
tickWindowsOverlayPointerInteraction(deps);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { OverlayContentMeasurement, OverlayContentRect } from '../../types';
|
||||||
|
|
||||||
|
type PointerPoint = { x: number; y: number };
|
||||||
|
type PointerRect = { x: number; y: number; width: number; height: number };
|
||||||
|
|
||||||
|
type PointerInteractionWindow = {
|
||||||
|
isDestroyed: () => boolean;
|
||||||
|
isVisible: () => boolean;
|
||||||
|
getBounds: () => PointerRect;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WindowsOverlayPointerInteractionDeps = {
|
||||||
|
getVisibleOverlayVisible: () => boolean;
|
||||||
|
getMainWindow: () => PointerInteractionWindow | null;
|
||||||
|
getCursorScreenPoint: () => PointerPoint;
|
||||||
|
getSubtitleMeasurement: () => OverlayContentMeasurement | null;
|
||||||
|
getRendererInteractiveHint?: () => boolean;
|
||||||
|
/** True when a modal/stats/separate window owns input. */
|
||||||
|
shouldSuspend: () => boolean;
|
||||||
|
getInteractionActive: () => boolean;
|
||||||
|
setInteractionActive: (active: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Match Linux fallback padding so hover survives tiny measurement/cursor gaps.
|
||||||
|
const SUBTITLE_HIT_PADDING_PX = 6;
|
||||||
|
|
||||||
|
function measuredRectsForInput(
|
||||||
|
measurement: OverlayContentMeasurement | null,
|
||||||
|
): OverlayContentRect[] {
|
||||||
|
if (!measurement) return [];
|
||||||
|
return Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.length > 0
|
||||||
|
? measurement.interactiveRects
|
||||||
|
: measurement.contentRect
|
||||||
|
? [measurement.contentRect]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCursorOverRect(
|
||||||
|
cursor: PointerPoint,
|
||||||
|
bounds: PointerRect,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
rect: OverlayContentRect,
|
||||||
|
): boolean {
|
||||||
|
if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
|
||||||
|
if (!(viewport.width > 0) || !(viewport.height > 0)) return false;
|
||||||
|
if (!(rect.width > 0) || !(rect.height > 0)) return false;
|
||||||
|
|
||||||
|
const scaleX = bounds.width / viewport.width;
|
||||||
|
const scaleY = bounds.height / viewport.height;
|
||||||
|
const left = bounds.x + rect.x * scaleX - SUBTITLE_HIT_PADDING_PX;
|
||||||
|
const top = bounds.y + rect.y * scaleY - SUBTITLE_HIT_PADDING_PX;
|
||||||
|
const right = left + rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2;
|
||||||
|
const bottom = top + rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2;
|
||||||
|
|
||||||
|
return cursor.x >= left && cursor.x <= right && cursor.y >= top && cursor.y <= bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCursorOverWindowsOverlayInteractiveRect(
|
||||||
|
cursor: PointerPoint,
|
||||||
|
bounds: PointerRect,
|
||||||
|
measurement: OverlayContentMeasurement | null,
|
||||||
|
): boolean {
|
||||||
|
if (!measurement) return false;
|
||||||
|
return measuredRectsForInput(measurement).some((rect) =>
|
||||||
|
isCursorOverRect(cursor, bounds, measurement.viewport, rect),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the desired Windows overlay mouse-input state, or null when another surface
|
||||||
|
* currently owns interaction and the fallback should not touch BrowserWindow passthrough.
|
||||||
|
*/
|
||||||
|
export function resolveDesiredWindowsOverlayInteractive(
|
||||||
|
deps: WindowsOverlayPointerInteractionDeps,
|
||||||
|
): boolean | null {
|
||||||
|
if (!deps.getVisibleOverlayVisible()) return false;
|
||||||
|
if (deps.shouldSuspend()) return null;
|
||||||
|
|
||||||
|
const mainWindow = deps.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deps.getRendererInteractiveHint?.()) return true;
|
||||||
|
return isCursorOverWindowsOverlayInteractiveRect(
|
||||||
|
deps.getCursorScreenPoint(),
|
||||||
|
mainWindow.getBounds(),
|
||||||
|
deps.getSubtitleMeasurement(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tickWindowsOverlayPointerInteraction(
|
||||||
|
deps: WindowsOverlayPointerInteractionDeps,
|
||||||
|
): void {
|
||||||
|
const desired = resolveDesiredWindowsOverlayInteractive(deps);
|
||||||
|
if (desired === null) return;
|
||||||
|
if (deps.getInteractionActive() === desired) return;
|
||||||
|
deps.setInteractionActive(desired);
|
||||||
|
}
|
||||||
+34
-12
@@ -14,23 +14,31 @@ async function withStubbedFfmpeg(
|
|||||||
const tempDir = path.join(root, 'media');
|
const tempDir = path.join(root, 'media');
|
||||||
const argsPath = path.join(root, 'ffmpeg-args.txt');
|
const argsPath = path.join(root, 'ffmpeg-args.txt');
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
const ffmpegPath = path.join(binDir, 'ffmpeg');
|
const ffmpegStubPath = path.join(binDir, 'ffmpeg-stub.cjs');
|
||||||
|
const ffmpegPath = path.join(binDir, process.platform === 'win32' ? 'ffmpeg.cmd' : 'ffmpeg');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
ffmpegPath,
|
ffmpegStubPath,
|
||||||
[
|
[
|
||||||
'#!/bin/sh',
|
"const fs = require('node:fs');",
|
||||||
'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then',
|
'const args = process.argv.slice(2);',
|
||||||
' echo " V..... libaom-av1"',
|
"if (args[0] === '-hide_banner' && args[1] === '-encoders') {",
|
||||||
' exit 0',
|
" console.log(' V..... libaom-av1');",
|
||||||
'fi',
|
' process.exit(0);',
|
||||||
'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"',
|
'}',
|
||||||
'out=""',
|
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, `${args.join('\\n')}\\n`, 'utf8');",
|
||||||
'for arg in "$@"; do out="$arg"; done',
|
'const outputPath = args.at(-1);',
|
||||||
'printf avif > "$out"',
|
"fs.writeFileSync(outputPath, 'avif', 'utf8');",
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
fs.chmodSync(ffmpegPath, 0o755);
|
const ffmpegStub =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? ['@echo off', `"${process.execPath}" "${ffmpegStubPath}" %*`].join('\r\n')
|
||||||
|
: ['#!/bin/sh', `exec "${process.execPath}" "${ffmpegStubPath}" "$@"`].join('\n');
|
||||||
|
fs.writeFileSync(ffmpegPath, ffmpegStub, 'utf8');
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
fs.chmodSync(ffmpegPath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
const originalPath = process.env.PATH;
|
const originalPath = process.env.PATH;
|
||||||
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
||||||
@@ -160,3 +168,17 @@ test('generateAudio clips leading padding without adding it to trailing duration
|
|||||||
assert.equal(args[args.indexOf('-t') + 1], '1.7');
|
assert.equal(args[args.indexOf('-t') + 1], '1.7');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generateAudio recreates missing temp directory before invoking ffmpeg', async () => {
|
||||||
|
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||||
|
const tempDir = (generator as unknown as { tempDir: string }).tempDir;
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
await generator.generateAudio('/video.mp4', 10, 12);
|
||||||
|
|
||||||
|
const args = readFfmpegArgs(argsPath);
|
||||||
|
const outputPath = args.at(-1);
|
||||||
|
assert.equal(typeof outputPath, 'string');
|
||||||
|
assert.equal(fs.existsSync(path.dirname(outputPath!)), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+18
-10
@@ -77,16 +77,23 @@ export class MediaGenerator {
|
|||||||
constructor(tempDir?: string) {
|
constructor(tempDir?: string) {
|
||||||
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
|
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
|
||||||
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
|
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
|
||||||
if (!fs.existsSync(this.tempDir)) {
|
this.ensureDirectory(this.tempDir);
|
||||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
this.ensureDirectory(this.notifyIconDir);
|
||||||
}
|
|
||||||
if (!fs.existsSync(this.notifyIconDir)) {
|
|
||||||
fs.mkdirSync(this.notifyIconDir, { recursive: true });
|
|
||||||
}
|
|
||||||
// Clean up old notification icons on startup (older than 1 hour)
|
// Clean up old notification icons on startup (older than 1 hour)
|
||||||
this.cleanupOldNotificationIcons();
|
this.cleanupOldNotificationIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureDirectory(dir: string): void {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTempOutputPath(prefix: string, extension: string): string {
|
||||||
|
this.ensureDirectory(this.tempDir);
|
||||||
|
return path.join(this.tempDir, `${prefix}_${Date.now()}.${extension}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up notification icons older than 1 hour.
|
* Clean up notification icons older than 1 hour.
|
||||||
* Called on startup to prevent accumulation of temp files.
|
* Called on startup to prevent accumulation of temp files.
|
||||||
@@ -121,6 +128,7 @@ export class MediaGenerator {
|
|||||||
* compatibility with Linux/Wayland notification daemons.
|
* compatibility with Linux/Wayland notification daemons.
|
||||||
*/
|
*/
|
||||||
writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string {
|
writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string {
|
||||||
|
this.ensureDirectory(this.notifyIconDir);
|
||||||
const filename = `icon_${noteId}_${Date.now()}.png`;
|
const filename = `icon_${noteId}_${Date.now()}.png`;
|
||||||
const filePath = path.join(this.notifyIconDir, filename);
|
const filePath = path.join(this.notifyIconDir, filename);
|
||||||
fs.writeFileSync(filePath, iconBuffer);
|
fs.writeFileSync(filePath, iconBuffer);
|
||||||
@@ -184,7 +192,7 @@ export class MediaGenerator {
|
|||||||
const duration = endTime - start + safePadding;
|
const duration = endTime - start + safePadding;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
|
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
||||||
const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath];
|
const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -261,7 +269,7 @@ export class MediaGenerator {
|
|||||||
args.push('-y');
|
args.push('-y');
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = path.join(this.tempDir, `screenshot_${Date.now()}.${ext}`);
|
const outputPath = this.createTempOutputPath('screenshot', ext);
|
||||||
args.push(outputPath);
|
args.push(outputPath);
|
||||||
|
|
||||||
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
||||||
@@ -288,7 +296,7 @@ export class MediaGenerator {
|
|||||||
*/
|
*/
|
||||||
async generateNotificationIcon(videoPath: string, timestamp: number): Promise<Buffer> {
|
async generateNotificationIcon(videoPath: string, timestamp: number): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = path.join(this.tempDir, `notify_icon_${Date.now()}.png`);
|
const outputPath = this.createTempOutputPath('notify_icon', 'png');
|
||||||
|
|
||||||
execFile(
|
execFile(
|
||||||
'ffmpeg',
|
'ffmpeg',
|
||||||
@@ -355,7 +363,7 @@ export class MediaGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = path.join(this.tempDir, `animation_${Date.now()}.avif`);
|
const outputPath = this.createTempOutputPath('animation', 'avif');
|
||||||
|
|
||||||
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
||||||
if (av1Encoder === 'libaom-av1') {
|
if (av1Encoder === 'libaom-av1') {
|
||||||
|
|||||||
Reference in New Issue
Block a user