diff --git a/changes/anki-highlight-word-fallback.md b/changes/anki-highlight-word-fallback.md new file mode 100644 index 00000000..aac364ea --- /dev/null +++ b/changes/anki-highlight-word-fallback.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed Highlight Word not bolding the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence field did not already contain bold markup. diff --git a/changes/kiku-lapis-word-sentence-card.md b/changes/kiku-lapis-word-sentence-card.md new file mode 100644 index 00000000..b8c00609 --- /dev/null +++ b/changes/kiku-lapis-word-sentence-card.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed Lapis/Kiku word cards enriched through SubMiner missing the word-and-sentence marker, which could hide sentence context on the card front. diff --git a/changes/windows-anki-media-startup.md b/changes/windows-anki-media-startup.md new file mode 100644 index 00000000..8bd45ed8 --- /dev/null +++ b/changes/windows-anki-media-startup.md @@ -0,0 +1,5 @@ +type: fixed +area: anki + +- Fixed known-word cache refreshes without a configured deck by using AnkiConnect's valid all-notes query instead of `is:note`. +- Fixed Windows media generation after background launches by recreating missing FFmpeg temp output directories before clipping audio or images. diff --git a/changes/windows-character-dictionary-mpv-path.md b/changes/windows-character-dictionary-mpv-path.md new file mode 100644 index 00000000..53a21696 --- /dev/null +++ b/changes/windows-character-dictionary-mpv-path.md @@ -0,0 +1,4 @@ +type: fixed +area: dictionary + +- Fixed Windows `SubMiner mpv` shortcut launches so character dictionary auto-sync can fall back to mpv's current video path when app media state is not ready yet. diff --git a/changes/windows-overlay-hover.md b/changes/windows-overlay-hover.md new file mode 100644 index 00000000..497d866e --- /dev/null +++ b/changes/windows-overlay-hover.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed shaky Windows subtitle-bar hover/click interaction when a video attaches to an already-running background SubMiner app. diff --git a/main-entry.js b/main-entry.js new file mode 100644 index 00000000..882f99fe --- /dev/null +++ b/main-entry.js @@ -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 \ No newline at end of file diff --git a/package.json b/package.json index 05001678..5e813302 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/shared/mpv-x11-backend.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/overlay-runtime.test.ts src/main/runtime/macos-mpv-focus.test.ts src/main/runtime/macos-modal-focus-handoff.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/visible-overlay-autoplay-readiness.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/core/utils/electron-backend.test.ts src/core/utils/notification.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/linux-overlay-pointer-interaction.test.ts src/main/runtime/linux-overlay-zorder-keepalive.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/windows-mpv-plugin-detection.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/overlay-content-measurement.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-config.test.ts src/core/services/overlay-window.test.ts src/main/main-wiring.test.ts src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/overlay-modal-input-state.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-layout-main-deps.test.ts src/main/runtime/overlay-window-layout.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/yomitan-extension-overlay-reload.test.ts src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts src/main/runtime/linux-visible-overlay-window-mode.test.ts src/main/runtime/linux-x11-cursor-point.test.ts src/renderer/renderer-init-order.test.ts", - "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/shared/setup-state.test.js dist/shared/mpv-x11-backend.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/yomitan-extension-loader.test.js dist/core/services/yomitan-settings.test.js dist/core/services/settings-window-z-order.test.js dist/core/services/hyprland-window-placement.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/__tests__/stats-server.test.js dist/main/runtime/stats-server-routing.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/overlay-runtime.test.js dist/main/runtime/macos-mpv-focus.test.js dist/main/runtime/macos-modal-focus-handoff.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-ready-gate.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/main/runtime/visible-overlay-autoplay-readiness.test.js dist/main/runtime/character-dictionary-manager-gate.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/core/utils/shortcut-config.test.js dist/core/utils/electron-backend.test.js dist/core/utils/notification.test.js dist/main/runtime/startup-mode-flags.test.js dist/main/runtime/linux-overlay-pointer-interaction.test.js dist/main/runtime/linux-overlay-zorder-keepalive.test.js dist/main/runtime/config-settings-window.test.js dist/main/runtime/settings-window-z-order.test.js dist/main/runtime/setup-window-factory.test.js dist/main/runtime/first-run-setup-plugin.test.js dist/main/runtime/windows-mpv-plugin-detection.test.js dist/main/runtime/first-run-setup-service.test.js dist/main/runtime/first-run-setup-window.test.js dist/main/runtime/command-line-launcher.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/composers/cli-startup-composer.test.js dist/main/runtime/log-export.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/tray-main-actions.test.js dist/main/runtime/tray-main-deps.test.js dist/main/runtime/tray-runtime-handlers.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/app-ready-main-deps.test.js dist/main/runtime/update/appimage-updater.test.js dist/main/runtime/update/fetch-adapter.test.js dist/main/runtime/update/release-metadata-policy.test.js dist/main/runtime/update/update-dialogs.test.js dist/main/runtime/update/support-assets.test.js dist/renderer/error-recovery.test.js dist/renderer/overlay-content-measurement.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/main/character-dictionary-runtime/term-building.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js dist/core/services/overlay-visibility.test.js dist/core/services/overlay-window-config.test.js dist/core/services/overlay-window.test.js dist/main/main-wiring.test.js dist/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/overlay-modal-input-state.test.js dist/main/runtime/overlay-window-factory-main-deps.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/overlay-window-layout-main-deps.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/overlay-window-runtime-handlers.test.js dist/main/runtime/yomitan-extension-overlay-reload.test.js dist/renderer/modals/subtitle-sidebar.test.js dist/renderer/overlay-mouse-ignore.test.js dist/main/runtime/linux-visible-overlay-window-mode.test.js dist/main/runtime/linux-x11-cursor-point.test.js dist/renderer/renderer-init-order.test.js", + "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/shared/mpv-x11-backend.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/overlay-runtime.test.ts src/main/runtime/macos-mpv-focus.test.ts src/main/runtime/macos-modal-focus-handoff.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/visible-overlay-autoplay-readiness.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/core/utils/electron-backend.test.ts src/core/utils/notification.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/linux-overlay-pointer-interaction.test.ts src/main/runtime/windows-overlay-pointer-interaction.test.ts src/main/runtime/linux-overlay-zorder-keepalive.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/windows-mpv-plugin-detection.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/overlay-content-measurement.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-config.test.ts src/core/services/overlay-window.test.ts src/main/main-wiring.test.ts src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/overlay-modal-input-state.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-layout-main-deps.test.ts src/main/runtime/overlay-window-layout.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/yomitan-extension-overlay-reload.test.ts src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts src/main/runtime/linux-visible-overlay-window-mode.test.ts src/main/runtime/linux-x11-cursor-point.test.ts src/renderer/renderer-init-order.test.ts", + "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/shared/setup-state.test.js dist/shared/mpv-x11-backend.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/yomitan-extension-loader.test.js dist/core/services/yomitan-settings.test.js dist/core/services/settings-window-z-order.test.js dist/core/services/hyprland-window-placement.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/__tests__/stats-server.test.js dist/main/runtime/stats-server-routing.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/overlay-runtime.test.js dist/main/runtime/macos-mpv-focus.test.js dist/main/runtime/macos-modal-focus-handoff.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-ready-gate.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/main/runtime/visible-overlay-autoplay-readiness.test.js dist/main/runtime/character-dictionary-manager-gate.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/core/utils/shortcut-config.test.js dist/core/utils/electron-backend.test.js dist/core/utils/notification.test.js dist/main/runtime/startup-mode-flags.test.js dist/main/runtime/linux-overlay-pointer-interaction.test.js dist/main/runtime/windows-overlay-pointer-interaction.test.js dist/main/runtime/linux-overlay-zorder-keepalive.test.js dist/main/runtime/config-settings-window.test.js dist/main/runtime/settings-window-z-order.test.js dist/main/runtime/setup-window-factory.test.js dist/main/runtime/first-run-setup-plugin.test.js dist/main/runtime/windows-mpv-plugin-detection.test.js dist/main/runtime/first-run-setup-service.test.js dist/main/runtime/first-run-setup-window.test.js dist/main/runtime/command-line-launcher.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/composers/cli-startup-composer.test.js dist/main/runtime/log-export.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/tray-main-actions.test.js dist/main/runtime/tray-main-deps.test.js dist/main/runtime/tray-runtime-handlers.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/app-ready-main-deps.test.js dist/main/runtime/update/appimage-updater.test.js dist/main/runtime/update/fetch-adapter.test.js dist/main/runtime/update/release-metadata-policy.test.js dist/main/runtime/update/update-dialogs.test.js dist/main/runtime/update/support-assets.test.js dist/renderer/error-recovery.test.js dist/renderer/overlay-content-measurement.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/main/character-dictionary-runtime/term-building.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js dist/core/services/overlay-visibility.test.js dist/core/services/overlay-window-config.test.js dist/core/services/overlay-window.test.js dist/main/main-wiring.test.js dist/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/overlay-modal-input-state.test.js dist/main/runtime/overlay-window-factory-main-deps.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/overlay-window-layout-main-deps.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/overlay-window-runtime-handlers.test.js dist/main/runtime/yomitan-extension-overlay-reload.test.js dist/renderer/modals/subtitle-sidebar.test.js dist/renderer/overlay-mouse-ignore.test.js dist/main/runtime/linux-visible-overlay-window-mode.test.js dist/main/runtime/linux-x11-cursor-point.test.js dist/renderer/renderer-init-order.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index e3b4546d..77b757c5 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -95,7 +95,7 @@ function createIntegrationTestContext( knownWordsScope: string; knownWordsLastRefreshedAtMs: number; }; - privateState.knownWordsScope = 'is:note'; + privateState.knownWordsScope = 'all'; privateState.knownWordsLastRefreshedAtMs = Date.now(); return { @@ -324,6 +324,119 @@ test('AnkiIntegration resolves merged-away note ids to the kept note id', () => } }); +function processSentenceWithConfig( + config: Partial, + mpvSentence: string, + noteFields: Record, +): string { + const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never); + return ( + integration as unknown as { + processSentence: (sentence: string, fields: Record) => string; + } + ).processSentence(mpvSentence, noteFields); +} + +function processSentenceFuriganaWithConfig( + config: Partial, + sentenceFurigana: string, + noteFields: Record, +): string { + const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never); + return ( + integration as unknown as { + processSentenceFurigana: (sentence: string, fields: Record) => 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, '先日 貴様らが潜入した キールダンジョンから―'); +}); + +test('AnkiIntegration keeps existing Yomitan bold target when present', () => { + const processed = processSentenceWithConfig( + { + fields: { + word: 'Expression', + sentence: 'Sentence', + }, + behavior: { + highlightWord: true, + }, + }, + '先日 貴様らが潜入した キールダンジョンから―', + { + expression: '潜入', + sentence: '潜入した', + }, + ); + + assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―'); +}); + +test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => { + const processed = processSentenceWithConfig( + { + fields: { + word: 'Expression', + sentence: 'Sentence', + }, + behavior: { + highlightWord: false, + }, + }, + '先日 貴様らが潜入した キールダンジョンから―', + { + expression: '潜入', + sentence: '潜入', + }, + ); + + assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―'); +}); + +test('AnkiIntegration highlights mined word in sentence furigana field', () => { + const processed = processSentenceFuriganaWithConfig( + { + fields: { + word: 'Expression', + sentence: 'Sentence', + }, + behavior: { + highlightWord: true, + }, + }, + '不思議ふしぎ特技とくぎ', + { + expression: '特技', + sentence: '不思議な特技を', + }, + ); + + assert.equal( + processed, + '不思議ふしぎ特技とくぎ', + ); +}); + test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => { const integration = new AnkiIntegration( { diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 56bb4e00..6709d803 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -70,7 +70,7 @@ interface NoteInfo { fields: Record; } -type CardKind = 'sentence' | 'audio'; +type CardKind = 'sentence' | 'audio' | 'word-and-sentence'; function trimToNonEmptyString(value: unknown): string | null { if (typeof value !== 'string') return null; @@ -78,6 +78,66 @@ function trimToNonEmptyString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function stripRubyReadingText(value: string): string { + return value + .replace(/]*>[\s\S]*?<\/rt>/gi, '') + .replace(/]*>[\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 || /]*>[\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, `${highlightedText}`); + } + + 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)}${result.slice( + span.start, + span.end, + )}${result.slice(span.end)}`; + } + return result; +} + function decodeURIComponentSafe(value: string): string { try { return decodeURIComponent(value); @@ -461,6 +521,10 @@ export class AnkiIntegration { handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) => this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression), 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) => this.resolveConfiguredFieldName(noteInfo, ...preferredNames), getResolvedSentenceAudioFieldName: (noteInfo) => @@ -677,20 +741,25 @@ export class AnkiIntegration { return result; } + private getSentenceHighlightText(noteFields: Record): string { + const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence'; + const existingSentence = noteFields[sentenceFieldName] || ''; + return ( + existingSentence.match(/(.*?)<\/b>/)?.[1] || + getPreferredWordValueFromExtractedFields(noteFields, this.config).trim() + ); + } + private processSentence(mpvSentence: string, noteFields: Record): string { if (this.config.behavior?.highlightWord === false) { return mpvSentence; } - const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence'; - const existingSentence = noteFields[sentenceFieldName] || ''; - - const highlightMatch = existingSentence.match(/(.*?)<\/b>/); - if (!highlightMatch || !highlightMatch[1]) { + const highlightedText = this.getSentenceHighlightText(noteFields); + if (!highlightedText) { return mpvSentence; } - const highlightedText = highlightMatch[1]; const index = mpvSentence.indexOf(highlightedText); if (index === -1) { @@ -702,6 +771,20 @@ export class AnkiIntegration { return `${prefix}${highlightedText}${suffix}`; } + private processSentenceFurigana( + sentenceFurigana: string, + noteFields: Record, + ): string { + if (this.config.behavior?.highlightWord === false) { + return sentenceFurigana; + } + + const highlightedText = this.getSentenceHighlightText(noteFields); + return highlightedText + ? boldMatchingFuriganaTerms(sentenceFurigana, highlightedText) + : sentenceFurigana; + } + private consumeSubtitleMiningContext(): SubtitleMiningContext | null { if (!this.consumeSubtitleMiningContextCallback) { return null; @@ -1030,6 +1113,30 @@ export class AnkiIntegration { ): void { 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') { const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard'); if (sentenceFlag) { diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index ba76162e..a03714d1 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -6,6 +6,27 @@ import type { AnkiConnectConfig } from '../types/anki'; type CardCreationDeps = ConstructorParameters[0]; +function setWordAndSentenceCardTypeFields( + updatedFields: Record, + 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 = {}): { service: CardCreationService; updatedFields: Record[]; @@ -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 () => { const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({ client: { diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 1077ac0b..991d64ac 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -10,6 +10,7 @@ import { AiConfig } from '../types/integrations'; import { MpvClient } from '../types/runtime'; import { resolveSentenceBackText } from './ai'; import { resolveMediaGenerationInputPath } from './media-source'; +import { shouldMarkWordAndSentenceCard } from './note-field-utils'; const log = createLogger('anki').child('integration.card-creation'); @@ -18,7 +19,7 @@ export interface CardCreationNoteInfo { fields: Record; } -type CardKind = 'sentence' | 'audio'; +type CardKind = 'sentence' | 'audio' | 'word-and-sentence'; interface CardCreationClient { addNote( @@ -219,7 +220,8 @@ export class CardCreationService { this.deps.getConfig(), ); 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 updatedFields: Record = {}; @@ -230,6 +232,13 @@ export class CardCreationService { if (sentenceField) { const processedSentence = this.deps.processSentence(sentence, fields); updatedFields[sentenceField] = processedSentence; + if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) { + this.deps.setCardTypeFields( + updatedFields, + Object.keys(noteInfo.fields), + 'word-and-sentence', + ); + } updatePerformed = true; } diff --git a/src/anki-integration/known-word-cache.test.ts b/src/anki-integration/known-word-cache.test.ts index afb16011..ddd3a4e0 100644 --- a/src/anki-integration/known-word-cache.test.ts +++ b/src/anki-integration/known-word-cache.test.ts @@ -94,7 +94,7 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i JSON.stringify({ version: 2, refreshedAtMs: 120_000, - scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}', + scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}', words: ['猫'], notes: { '1': ['猫'], @@ -143,7 +143,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted JSON.stringify({ version: 2, refreshedAtMs: 59_000, - scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}', + scope: '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}', words: ['猫'], notes: { '1': ['猫'], @@ -229,7 +229,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited JSON.stringify({ version: 2, refreshedAtMs: 1, - scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}', + scope: '{"refreshMinutes":1440,"scope":"all","fieldsWord":"Word"}', words: ['猫', '犬'], notes: { '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 () => { const config: AnkiConnectConfig = { fields: { @@ -364,7 +394,7 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo scope: 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, ['猫']); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); @@ -568,7 +598,7 @@ test('KnownWordCacheManager reports immediate append cache clears as mutations', JSON.stringify({ version: 2, refreshedAtMs: Date.now(), - scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":"Expression"}', + scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":"Expression"}', words: ['猫'], notes: { '1': ['猫'], diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index d14cb52b..b5bb5f9a 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -48,7 +48,7 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri } const configuredDeck = trimToNonEmptyString(config.deck); - return configuredDeck ? `deck:${configuredDeck}` : 'is:note'; + return configuredDeck ? `deck:${configuredDeck}` : 'all'; } export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string { @@ -396,7 +396,7 @@ export class KnownWordCacheManager { private buildKnownWordsQuery(): string { const decks = this.getKnownWordDecks(); if (decks.length === 0) { - return 'is:note'; + return ''; } if (decks.length === 1) { diff --git a/src/anki-integration/note-field-utils.ts b/src/anki-integration/note-field-utils.ts new file mode 100644 index 00000000..b3370825 --- /dev/null +++ b/src/anki-integration/note-field-utils.ts @@ -0,0 +1,34 @@ +export interface NoteFieldValueInfo { + fields: Record; +} + +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') + ); +} diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 218236c0..0649281d 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -7,6 +7,27 @@ import { } from './note-update-workflow'; import type { SubtitleMiningContext } from '../types/subtitle'; +function setWordAndSentenceCardTypeFields( + updatedFields: Record, + 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() { const updates: Array<{ noteId: number; fields: Record }> = []; const notifications: Array<{ noteId: number; label: string | number }> = []; @@ -40,6 +61,7 @@ function createWorkflowHarness() { getCurrentSubtitleStart: () => 12.3, getEffectiveSentenceCardConfig: () => ({ sentenceField: 'Sentence', + lapisEnabled: false, kikuEnabled: false, kikuFieldGrouping: 'disabled' as const, }), @@ -57,6 +79,7 @@ function createWorkflowHarness() { handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) => false, processSentence: (text: string, _noteFields: Record) => text, + setCardTypeFields: setWordAndSentenceCardTypeFields, resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => { if (!preferred) return null; 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); }); +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: 'tokugi' }, + }, + }, + ] satisfies NoteUpdateWorkflowNoteInfo[]; + harness.deps.processSentenceFurigana = (sentenceFurigana) => + sentenceFurigana.replace('tokugi', 'tokugi'); + + await harness.workflow.execute(42); + + assert.equal(harness.updates.length, 1); + assert.deepEqual(harness.updates[0]?.fields, { + Sentence: 'subtitle-text', + SentenceFurigana: 'tokugi', + }); +}); + +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 () => { const harness = createWorkflowHarness(); harness.deps.client.notesInfo = async () => []; @@ -119,6 +254,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async ( let notesInfoCallCount = 0; harness.deps.getEffectiveSentenceCardConfig = () => ({ sentenceField: 'Sentence', + lapisEnabled: false, kikuEnabled: true, kikuFieldGrouping: 'auto', }); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index c0511dc3..e60efa35 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -1,6 +1,7 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; import type { SubtitleMiningContext } from '../types/subtitle'; +import { shouldMarkWordAndSentenceCard } from './note-field-utils'; export interface NoteUpdateWorkflowNoteInfo { noteId: number; @@ -35,6 +36,7 @@ export interface NoteUpdateWorkflowDeps { getCurrentSubtitleStart: () => number | undefined; getEffectiveSentenceCardConfig: () => { sentenceField: string; + lapisEnabled: boolean; kikuEnabled: boolean; kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; }; @@ -58,6 +60,15 @@ export interface NoteUpdateWorkflowDeps { expression: string, ) => Promise; processSentence: (mpvSentence: string, noteFields: Record) => string; + processSentenceFurigana?: ( + sentenceFurigana: string, + noteFields: Record, + ) => string; + setCardTypeFields: ( + updatedFields: Record, + availableFieldNames: string[], + cardKind: 'word-and-sentence', + ) => void; resolveConfiguredFieldName: ( noteInfo: NoteUpdateWorkflowNoteInfo, ...preferredNames: (string | undefined)[] @@ -189,8 +200,32 @@ export class NoteUpdateWorkflow { if (sentenceField && currentSubtitleText) { const processedSentence = this.deps.processSentence(currentSubtitleText, fields); updatedFields[sentenceField] = processedSentence; + if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) { + this.deps.setCardTypeFields( + updatedFields, + Object.keys(noteInfo.fields), + 'word-and-sentence', + ); + } 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) { try { diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 01b97ba9..ac68e8d5 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -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, 'を見た'); + 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 () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index c197a459..c031f7c4 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -204,6 +204,29 @@ function getStatsWordMiningAudioFieldName( ); } +function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean { + return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true; +} + +function applyStatsWordAndSentenceCardFields( + fields: Record, + 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( ankiConfig: AnkiConnectConfig, noteInfo: StatsServerNoteInfo | null, @@ -1299,7 +1322,11 @@ export function createStatsApp( let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; let noteInfo: StatsServerNoteInfo | null = null; - if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) { + if ( + audioBuffer || + (syncAnimatedImageToWordAudio && generateImage) || + shouldUseStatsLapisKikuCardFields(ankiConfig) + ) { try { const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; noteInfo = noteInfoResult[0] ?? null; @@ -1339,6 +1366,7 @@ export function createStatsApp( const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; mediaFields[sentenceFieldName] = highlightedSentence; + applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig); if (audioBuffer) { const audioFilename = `subminer_audio_${timestamp}.mp3`; diff --git a/src/main.ts b/src/main.ts index f2559a8c..90462b2e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2244,6 +2244,7 @@ const mediaRuntime = createMediaRuntimeService( const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({ userDataPath: USER_DATA_PATH, getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath, getCurrentMediaTitle: () => appState.currentMediaTitle, resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath), guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), @@ -2561,6 +2562,10 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void { visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(); } +function tickWindowsOverlayPointerInteractionNow(): void { + visibleOverlayInteractionRuntime.tickWindowsOverlayPointerInteractionNow(); +} + function scheduleVisibleOverlayBlurRefresh(): void { visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(); } @@ -5408,13 +5413,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ if (!mainWindow || senderWindow !== mainWindow) { return; } - if (visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() === active) { + const previousActive = + visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(); + visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active); + if (previousActive === active) { if (active && process.platform === 'darwin' && !mainWindow.isFocused()) { overlayVisibilityRuntime.updateVisibleOverlayVisibility(); } return; } - visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active); overlayVisibilityRuntime.updateVisibleOverlayVisibility(); }, onOverlayInteractiveHint: (interactive, senderWindow) => { @@ -5614,6 +5621,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ reportOverlayContentBounds: (payload: unknown) => { if (overlayContentMeasurementStore.report(payload)) { tickLinuxOverlayPointerInteractionNow(); + tickWindowsOverlayPointerInteractionNow(); primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); autoplayReadyGate.flushPendingAutoplayReadySignal(); scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); diff --git a/src/main/character-dictionary-runtime.test.ts b/src/main/character-dictionary-runtime.test.ts index a3166513..077835fd 100644 --- a/src/main/character-dictionary-runtime.test.ts +++ b/src/main/character-dictionary-runtime.test.ts @@ -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 () => { const userDataPath = makeTempDir(); const originalFetch = globalThis.fetch; diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index 8ed153e2..e0f4e02a 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -76,6 +76,11 @@ function expandUserPath(input: string): string { 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 { return hasVideoExtension(path.extname(filePath)); } @@ -195,8 +200,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar return dictionaryTarget.length > 0 ? resolveDictionaryGuessInputs(dictionaryTarget) : { - mediaPath: deps.getCurrentMediaPath(), - mediaTitle: deps.getCurrentMediaTitle(), + mediaPath: + trimToNull(deps.getCurrentMediaPath()) ?? trimToNull(deps.getCurrentVideoPath?.()), + mediaTitle: trimToNull(deps.getCurrentMediaTitle()), }; }; diff --git a/src/main/character-dictionary-runtime/types.ts b/src/main/character-dictionary-runtime/types.ts index 0385c966..622dbaba 100644 --- a/src/main/character-dictionary-runtime/types.ts +++ b/src/main/character-dictionary-runtime/types.ts @@ -137,6 +137,7 @@ export type CharacterDictionaryManualSelectionResult = { export interface CharacterDictionaryRuntimeDeps { userDataPath: string; getCurrentMediaPath: () => string | null; + getCurrentVideoPath?: () => string | null | undefined; getCurrentMediaTitle: () => string | null; resolveMediaPathForJimaku: (mediaPath: string | null) => string | null; guessAnilistMediaInfo: ( diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index d6e1fb17..2477b196 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -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 measurementBlock = source.match( /reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?[\s\S]*?)\n \},/, @@ -317,6 +317,7 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i assert.ok(measurementBlock); assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/); assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/); + assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/); assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/); assert.ok( measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') < @@ -324,6 +325,10 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i ); assert.ok( measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') < + measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'), + ); + assert.ok( + measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') < measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'), ); }); diff --git a/src/main/runtime/visible-overlay-interaction-runtime.ts b/src/main/runtime/visible-overlay-interaction-runtime.ts index 26f495fd..433aa244 100644 --- a/src/main/runtime/visible-overlay-interaction-runtime.ts +++ b/src/main/runtime/visible-overlay-interaction-runtime.ts @@ -32,6 +32,7 @@ import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point'; import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode'; import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility'; import { hasLiveSeparateWindow } from './settings-window-z-order'; +import { tickWindowsOverlayPointerInteraction } from './windows-overlay-pointer-interaction'; export interface VisibleOverlayInteractionRuntimeDeps { overlayManager: { @@ -89,6 +90,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter let windowsVisibleOverlayZOrderSyncInFlight = false; let windowsVisibleOverlayZOrderSyncQueued = false; let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; + let windowsOverlayPointerInteractionActive = false; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayBlurredAtMs = 0; let lastLinuxVisibleOverlayFollowedMpvAtMs = 0; @@ -122,6 +124,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter function resetVisibleOverlayInputState(): void { visibleOverlayInteractionActive = false; + windowsOverlayPointerInteractionActive = false; linuxOverlayInputShapeActive = false; linuxOverlayPointerInteractionStateApplied = false; resetLinuxVisibleOverlayStartupInputPrimer(); @@ -538,6 +541,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter windowsVisibleOverlayForegroundPollInterval = setInterval(() => { maybePollWindowsVisibleOverlayForegroundProcess(); + tickWindowsOverlayPointerInteractionNow(); }, 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(); const linuxX11CursorPointReader = createLinuxX11CursorPointReader(); @@ -811,10 +865,12 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter updateLinuxOverlayPointerInteractionActive, primeLinuxOverlayPointerInteractionAfterFirstMeasurement, requestLinuxOverlayZOrderFollow, + tickWindowsOverlayPointerInteractionNow, tickLinuxOverlayPointerInteractionNow, getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive, setVisibleOverlayInteractionActive: (active: boolean) => { visibleOverlayInteractionActive = active; + windowsOverlayPointerInteractionActive = false; }, getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive, getLastWindowsVisibleOverlayForegroundProcessName: () => diff --git a/src/main/runtime/windows-overlay-pointer-interaction.test.ts b/src/main/runtime/windows-overlay-pointer-interaction.test.ts new file mode 100644 index 00000000..a1709d15 --- /dev/null +++ b/src/main/runtime/windows-overlay-pointer-interaction.test.ts @@ -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): { + 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, []); +}); diff --git a/src/main/runtime/windows-overlay-pointer-interaction.ts b/src/main/runtime/windows-overlay-pointer-interaction.ts new file mode 100644 index 00000000..3149b3c4 --- /dev/null +++ b/src/main/runtime/windows-overlay-pointer-interaction.ts @@ -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); +} diff --git a/src/media-generator.test.ts b/src/media-generator.test.ts index 700cbabf..0eaa57c9 100644 --- a/src/media-generator.test.ts +++ b/src/media-generator.test.ts @@ -14,23 +14,31 @@ async function withStubbedFfmpeg( const tempDir = path.join(root, 'media'); const argsPath = path.join(root, 'ffmpeg-args.txt'); 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( - ffmpegPath, + ffmpegStubPath, [ - '#!/bin/sh', - 'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then', - ' echo " V..... libaom-av1"', - ' exit 0', - 'fi', - 'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"', - 'out=""', - 'for arg in "$@"; do out="$arg"; done', - 'printf avif > "$out"', + "const fs = require('node:fs');", + 'const args = process.argv.slice(2);', + "if (args[0] === '-hide_banner' && args[1] === '-encoders') {", + " console.log(' V..... libaom-av1');", + ' process.exit(0);', + '}', + "fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, `${args.join('\\n')}\\n`, 'utf8');", + 'const outputPath = args.at(-1);', + "fs.writeFileSync(outputPath, 'avif', 'utf8');", ].join('\n'), '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 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'); }); }); + +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); + }); +}); diff --git a/src/media-generator.ts b/src/media-generator.ts index d9a02c57..1ca0bc75 100644 --- a/src/media-generator.ts +++ b/src/media-generator.ts @@ -77,16 +77,23 @@ export class MediaGenerator { constructor(tempDir?: string) { this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media'); this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify'); - if (!fs.existsSync(this.tempDir)) { - fs.mkdirSync(this.tempDir, { recursive: true }); - } - if (!fs.existsSync(this.notifyIconDir)) { - fs.mkdirSync(this.notifyIconDir, { recursive: true }); - } + this.ensureDirectory(this.tempDir); + this.ensureDirectory(this.notifyIconDir); // Clean up old notification icons on startup (older than 1 hour) 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. * Called on startup to prevent accumulation of temp files. @@ -121,6 +128,7 @@ export class MediaGenerator { * compatibility with Linux/Wayland notification daemons. */ writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string { + this.ensureDirectory(this.notifyIconDir); const filename = `icon_${noteId}_${Date.now()}.png`; const filePath = path.join(this.notifyIconDir, filename); fs.writeFileSync(filePath, iconBuffer); @@ -184,7 +192,7 @@ export class MediaGenerator { const duration = endTime - start + safePadding; 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]; if ( @@ -261,7 +269,7 @@ export class MediaGenerator { args.push('-y'); return new Promise((resolve, reject) => { - const outputPath = path.join(this.tempDir, `screenshot_${Date.now()}.${ext}`); + const outputPath = this.createTempOutputPath('screenshot', ext); args.push(outputPath); execFile('ffmpeg', args, { timeout: 30000 }, (error) => { @@ -288,7 +296,7 @@ export class MediaGenerator { */ async generateNotificationIcon(videoPath: string, timestamp: number): Promise { return new Promise((resolve, reject) => { - const outputPath = path.join(this.tempDir, `notify_icon_${Date.now()}.png`); + const outputPath = this.createTempOutputPath('notify_icon', 'png'); execFile( 'ffmpeg', @@ -355,7 +363,7 @@ export class MediaGenerator { } 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]; if (av1Encoder === 'libaom-av1') {