From 579661fbef7080204259d0e0191a8eb47e1db0fa Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 02:44:35 -0800 Subject: [PATCH 01/74] refactor runtime deps wiring and docs/config updates --- Makefile | 6 +- README.md | 3 +- config.example.jsonc | 5 +- docs/README.md | 6 +- docs/architecture.md | 108 ++++--- docs/configuration.md | 2 +- docs/development.md | 7 +- docs/index.md | 4 +- docs/installation.md | 2 +- docs/public/config.example.jsonc | 2 +- docs/usage.md | 10 +- package.json | 8 +- src/config/template.ts | 2 +- ...ki-jimaku-ipc-deps-runtime-service.test.ts | 48 --- .../anki-jimaku-ipc-deps-runtime-service.ts | 32 -- src/core/services/index.ts | 26 +- .../mining-runtime-deps-service.test.ts | 65 ---- .../services/mining-runtime-deps-service.ts | 107 ------- .../mpv-client-deps-runtime-service.test.ts | 51 ---- .../mpv-client-deps-runtime-service.ts | 61 ---- ...s => overlay-deps-runtime-service.test.ts} | 2 +- ...ice.ts => overlay-deps-runtime-service.ts} | 0 .../services/overlay-manager-service.test.ts | 42 +++ src/core/services/overlay-manager-service.ts | 49 +++ ...rlay-shortcut-runtime-deps-service.test.ts | 52 ---- .../overlay-shortcut-runtime-deps-service.ts | 59 ---- ...bility-facade-deps-runtime-service.test.ts | 46 --- ...-visibility-facade-deps-runtime-service.ts | 35 --- ... shortcut-ui-deps-runtime-service.test.ts} | 37 +-- .../shortcut-ui-deps-runtime-service.ts | 24 ++ .../shortcut-ui-runtime-deps-service.ts | 83 ----- ...startup-lifecycle-hooks-runtime-service.ts | 12 +- ...tup-lifecycle-runtime-deps-service.test.ts | 74 ----- .../startup-lifecycle-runtime-deps-service.ts | 55 ---- src/main.ts | 289 ++++++++++-------- 35 files changed, 372 insertions(+), 1042 deletions(-) delete mode 100644 src/core/services/anki-jimaku-ipc-deps-runtime-service.test.ts delete mode 100644 src/core/services/anki-jimaku-ipc-deps-runtime-service.ts delete mode 100644 src/core/services/mining-runtime-deps-service.test.ts delete mode 100644 src/core/services/mining-runtime-deps-service.ts delete mode 100644 src/core/services/mpv-client-deps-runtime-service.test.ts delete mode 100644 src/core/services/mpv-client-deps-runtime-service.ts rename src/core/services/{overlay-runtime-deps-service.test.ts => overlay-deps-runtime-service.test.ts} (98%) rename src/core/services/{overlay-runtime-deps-service.ts => overlay-deps-runtime-service.ts} (100%) create mode 100644 src/core/services/overlay-manager-service.test.ts create mode 100644 src/core/services/overlay-manager-service.ts delete mode 100644 src/core/services/overlay-shortcut-runtime-deps-service.test.ts delete mode 100644 src/core/services/overlay-shortcut-runtime-deps-service.ts delete mode 100644 src/core/services/overlay-visibility-facade-deps-runtime-service.test.ts delete mode 100644 src/core/services/overlay-visibility-facade-deps-runtime-service.ts rename src/core/services/{shortcut-ui-runtime-deps-service.test.ts => shortcut-ui-deps-runtime-service.test.ts} (54%) create mode 100644 src/core/services/shortcut-ui-deps-runtime-service.ts delete mode 100644 src/core/services/shortcut-ui-runtime-deps-service.ts delete mode 100644 src/core/services/startup-lifecycle-runtime-deps-service.test.ts delete mode 100644 src/core/services/startup-lifecycle-runtime-deps-service.ts diff --git a/Makefile b/Makefile index 381e2aa..65908f9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help deps build install build-linux build-macos build-macos-unsigned install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-pnpm generate-config generate-example-config docs-dev docs-build docs-preview +.PHONY: help deps build install build-linux build-macos build-macos-unsigned install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-pnpm generate-config generate-example-config docs-dev docs docs-preview APP_NAME := subminer THEME_FILE := subminer.rasi @@ -49,7 +49,7 @@ help: " build-macos Build macOS DMG/ZIP (signed if configured)" \ " build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \ " docs-dev Run VitePress docs dev server" \ - " docs-build Build VitePress static docs" \ + " docs Build VitePress static docs" \ " docs-preview Preview built VitePress docs" \ " install-linux Install Linux wrapper/theme/app artifacts" \ " install-macos Install macOS wrapper/theme/app artifacts" \ @@ -137,7 +137,7 @@ generate-example-config: ensure-pnpm docs-dev: ensure-pnpm @pnpm run docs:dev -docs-build: ensure-pnpm +docs: ensure-pnpm @pnpm run docs:build docs-preview: ensure-pnpm diff --git a/README.md b/README.md index 2520e3c..61796bb 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ For macOS app bundle / signing / permissions details, use `docs/installation.md` ## Quick Start -1. Copy and customize [`config.example.jsonc`](config.example.jsonc) to `~/.config/SubMiner/config.jsonc`. +1. Copy and customize [`config.example.jsonc`](config.example.jsonc) to `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` if `XDG_CONFIG_HOME` is unset). 2. Start mpv with IPC enabled: ```bash @@ -100,6 +100,7 @@ Detailed guides live in [`docs/`](docs/README.md): - [Usage](docs/usage.md) - [Configuration](docs/configuration.md) - [Development](docs/development.md) +- [Architecture](docs/architecture.md) (includes `OverlayManager` state ownership and deps wiring rules) ### Third-Party Components diff --git a/config.example.jsonc b/config.example.jsonc index 64c607b..2e7ebc2 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -2,7 +2,7 @@ * SubMiner Example Configuration File * * This file is auto-generated from src/config/definitions.ts. - * Copy to ~/.config/SubMiner/config.jsonc and edit as needed. + * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. */ { @@ -115,7 +115,8 @@ "multiCopyTimeoutMs": 3000, "toggleSecondarySub": "CommandOrControl+Shift+V", "markAudioCard": "CommandOrControl+Shift+A", - "openRuntimeOptions": "CommandOrControl+Shift+O" + "openRuntimeOptions": "CommandOrControl+Shift+O", + "openJimaku": "Ctrl+Alt+J" }, // ========================================== diff --git a/docs/README.md b/docs/README.md index 57df32c..a6d58ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,10 +28,10 @@ pnpm run docs:build - Full config file reference and option details - [Development](/development) - Contributor notes - - Architecture migration overview + - Architecture and extension rules - Environment variables - License and acknowledgments - [Architecture](/architecture) - - Composability migration status - - Core runtime structure + - Service-oriented runtime structure + - Composition and lifecycle model - Extension design rules diff --git a/docs/architecture.md b/docs/architecture.md index 1145a8a..be179ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,51 +1,75 @@ # Architecture -SubMiner is migrating from a single, monolithic `src/main.ts` runtime toward a composable architecture with clear extension points. +SubMiner uses a service-oriented Electron main-process architecture where `src/main.ts` acts as the composition root and behavior lives in small runtime services under `src/core/services`. + +## Goals + +- Keep behavior stable while reducing coupling. +- Prefer small, single-purpose units that can be tested in isolation. +- Keep `main.ts` focused on wiring and state ownership, not implementation detail. +- Follow Unix-style composability: + - each service does one job + - services compose through explicit inputs/outputs + - orchestration is separate from implementation ## Current Structure -- `src/main.ts`: bootstrap/composition root plus remaining legacy orchestration. -- `src/core/`: shared runtime primitives: - - `app-orchestrator.ts`: lifecycle wiring for ready/activate/quit hooks. - - `action-bus.ts`: typed action dispatch path. - - `actions.ts`: canonical app action types. - - `module.ts` / `module-registry.ts`: module lifecycle contract. - - `services/`: extracted runtime services (IPC, shortcuts, subtitle, overlay, MPV command routing). -- `src/ipc/`: shared IPC contract + wrappers used by main, preload, and renderer. -- `src/subtitle/`: staged subtitle pipeline (`normalize` -> `tokenize` -> `merge`). -- `src/modules/`: feature modules: - - `runtime-options` - - `jimaku` - - `subsync` - - `anki` - - `texthooker` -- provider registries: - - `src/window-trackers/index.ts` - - `src/tokenizers/index.ts` - - `src/token-mergers/index.ts` - - `src/translators/index.ts` - - `src/subsync/engines.ts` +- `src/main.ts` + - Composition root for lifecycle wiring and non-overlay runtime state. + - Owns long-lived process state for trackers, runtime flags, and client instances. + - Delegates behavior to services. +- `src/core/services/overlay-manager-service.ts` + - Owns overlay/window state (`mainWindow`, `invisibleWindow`, visible/invisible overlay flags). + - Provides a narrow state API used by `main.ts` and overlay services. +- `src/core/services/*` + - Stateless or narrowly stateful units for a specific responsibility. + - Examples: startup bootstrap, app lifecycle hooks, CLI command handling, IPC registration, overlay visibility, MPV IPC behavior, shortcut registration, subtitle websocket, jimaku/subsync helpers. +- `src/core/utils/*` + - Pure helpers and coercion/config utilities. +- `src/cli/*` + - CLI parsing and help output. +- `src/config/*` + - Config schema/definitions, defaults, validation, and template generation. +- `src/window-trackers/*` + - Backend-specific tracker implementations plus selection index. +- `src/jimaku/*`, `src/subsync/*` + - Domain-specific integration helpers. -## Migration Status +## Composition Pattern -- Completed: - - Action bus wired for CLI, shortcuts, and IPC-triggered commands. - - Command/action mapping covered by focused core tests. - - Shared IPC channel contract adopted across main/preload/renderer. - - Runtime options extracted into module lifecycle. - - Provider registries replace hardcoded backend selection. - - Subtitle tokenization/merge/enrich flow moved to staged pipeline. - - Stage-level subtitle pipeline tests added for deterministic behavior. - - Jimaku, Subsync, Anki, and texthooker/websocket flows moduleized. -- In progress: - - Further shrink `src/main.ts` by moving orchestration into dedicated services/orchestrator files. - - Continue moduleizing remaining integrations with complex lifecycle coupling. +Most runtime code follows a dependency-injection pattern: -## Design Rules +1. Define a service interface in `src/core/services/*`. +2. Keep core logic in pure or side-effect-bounded functions. +3. Build runtime deps in `main.ts`; use `*-deps-runtime-service.ts` helpers only when they add real adaptation logic. +4. Call the service from lifecycle/command wiring points. -- New feature behavior should be added as: - - a module (`src/modules/*`) and/or - - a provider registration (`src/*/index.ts` registries), - instead of editing unrelated branches in `src/main.ts`. -- New command triggers should dispatch `AppAction` and reuse existing action handlers. -- New IPC channels should be added only via `src/ipc/contract.ts`. +This keeps side effects explicit and makes behavior easy to unit-test with fakes. + +## Lifecycle Model + +- Startup: + - `startup-bootstrap-runtime-service` handles initial argv/env/backend setup and decides generate-config flow vs app lifecycle start. + - `app-lifecycle-service` handles Electron single-instance + lifecycle event registration. + - `startup-lifecycle-hooks-runtime-service` wires app-ready and app-shutdown hooks. +- Runtime: + - CLI/shortcut/IPC events map to service calls. + - Overlay and MPV state sync through dedicated services. + - Runtime options and mining flows are coordinated via service boundaries. +- Shutdown: + - `app-shutdown-runtime-service` coordinates cleanup ordering (shortcuts, sockets, trackers, integrations). + +## Why This Design + +- Smaller blast radius: changing one feature usually touches one service. +- Better testability: most behavior can be tested without Electron windows/mpv. +- Better reviewability: PRs can be scoped to one subsystem. +- Backward compatibility: CLI flags and IPC channels can remain stable while internals evolve. + +## Extension Rules + +- Add behavior to an existing service or a new `src/core/services/*` file, not as ad-hoc logic in `main.ts`. +- Keep service APIs explicit and narrowly scoped. +- Prefer additive changes that preserve existing CLI flags and IPC channel behavior. +- Add/update unit tests for each service extraction or behavior change. +- For cross-cutting changes, extract-first then refactor internals after parity is verified. diff --git a/docs/configuration.md b/docs/configuration.md index f5da729..d2815b7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -Settings are stored in `~/.config/SubMiner/config.jsonc` +Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset). For backward compatibility, SubMiner also reads existing configs from lowercase `subminer` directories. ### Configuration File diff --git a/docs/development.md b/docs/development.md index c40da38..69acbaa 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,7 +4,12 @@ To add or change a config option, update `src/config/definitions.ts` first. Defa ## Architecture -The composability migration state and extension-point guidelines are documented in [`architecture.md`](/architecture). +The current runtime design, composition model, and extension guidelines are documented in [`architecture.md`](/architecture). + +Contributor guidance: +- Overlay window/visibility state is owned by `src/core/services/overlay-manager-service.ts`. +- Prefer direct inline deps objects in `main.ts` for simple pass-through wiring. +- Add a `*-deps-runtime-service.ts` helper only when it performs meaningful adaptation (not identity mapping). ## Environment Variables diff --git a/docs/index.md b/docs/index.md index 114cd9a..f995aa9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,10 +34,10 @@ features: details: Build, test, and package SubMiner with the development notes in this docs set. --- -## Documentation Sections + diff --git a/docs/installation.md b/docs/installation.md index 3b84f23..5499ef4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -127,7 +127,7 @@ mpv --input-ipc-server=/tmp/subminer-socket video.mkv **Config Location:** -Settings are stored in `~/.config/SubMiner/config.jsonc` (same as Linux). +Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`, same as Linux). **MeCab Installation Paths:** diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index a1aebff..2e7ebc2 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -2,7 +2,7 @@ * SubMiner Example Configuration File * * This file is auto-generated from src/config/definitions.ts. - * Copy to ~/.config/SubMiner/config.jsonc and edit as needed. + * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. */ { diff --git a/docs/usage.md b/docs/usage.md index 8996fe3..cd63da5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,8 +7,6 @@ There are two ways to use SubMiner: | **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, starts the overlay automatically, and cleans up on exit. | | **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control visible and invisible overlay layers. Requires `--input-ipc-server=/tmp/subminer-socket`. | -Jimaku modal shortcut is an overlay shortcut, not an MPV plugin chord: default `Ctrl+Alt+J` via `shortcuts.openJimaku`. - You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow. `subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`. @@ -85,7 +83,7 @@ Notes: - Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset). - `subminer` prefers subtitle tracks from yt-dlp first, then falls back to local `whisper.cpp` (`whisper-cli`) when tracks are missing. - Whisper translation fallback currently only supports English secondary targets; non-English secondary targets rely on yt-dlp subtitle availability. -- Configure defaults in `~/.config/SubMiner/config.jsonc` under `youtubeSubgen` and `secondarySub`, or override mode/tool paths via CLI flags/environment variables. +- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtubeSubgen` and `secondarySub`, or override mode/tool paths via CLI flags/environment variables. ## Keybindings @@ -117,12 +115,6 @@ Notes: These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization. -### Overlay Chord Shortcuts - -| Chord | Action | -| --------- | ------------------------- | -| `y` → `j` | Open Jimaku subtitle menu | - ## How It Works 1. MPV runs with an IPC socket at `/tmp/subminer-socket` diff --git a/package.json b/package.json index 05a20d0..c740579 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,11 @@ "check:main-lines:gate3": "bash scripts/check-main-lines.sh 2500", "check:main-lines:gate4": "bash scripts/check-main-lines.sh 1800", "check:main-lines:gate5": "bash scripts/check-main-lines.sh 1500", - "docs:dev": "vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort", - "docs:build": "vitepress build docs", - "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", + "docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort", + "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", + "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "test:config": "pnpm run build && node --test dist/config/config.test.js", - "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/anki-jimaku-ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/subsync-deps-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/overlay-visibility-facade-deps-runtime-service.test.js dist/core/services/mpv-command-ipc-deps-runtime-service.test.js dist/core/services/runtime-options-ipc-deps-runtime-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/overlay-runtime-deps-service.test.js dist/core/services/startup-lifecycle-runtime-deps-service.test.js dist/core/services/startup-lifecycle-hooks-runtime-service.test.js dist/core/services/overlay-shortcut-runtime-deps-service.test.js dist/core/services/mining-runtime-deps-service.test.js dist/core/services/shortcut-ui-runtime-deps-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-visibility-facade-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js dist/core/services/app-ready-runtime-service.test.js dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/mpv-client-deps-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-runtime-service.test.js", + "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/subsync-deps-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/mpv-command-ipc-deps-runtime-service.test.js dist/core/services/runtime-options-ipc-deps-runtime-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/overlay-deps-runtime-service.test.js dist/core/services/startup-lifecycle-hooks-runtime-service.test.js dist/core/services/shortcut-ui-deps-runtime-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-visibility-facade-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/app-ready-runtime-service.test.js dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-runtime-service.test.js", "test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", "generate:config-example": "pnpm run build && node dist/generate-config-example.js", "start": "pnpm run build && electron . --start", diff --git a/src/config/template.ts b/src/config/template.ts index 7099f3a..5350945 100644 --- a/src/config/template.ts +++ b/src/config/template.ts @@ -55,7 +55,7 @@ export function generateConfigTemplate(config: ResolvedConfig = deepCloneConfig( lines.push(" * SubMiner Example Configuration File"); lines.push(" *"); lines.push(" * This file is auto-generated from src/config/definitions.ts."); - lines.push(" * Copy to ~/.config/SubMiner/config.jsonc and edit as needed."); + lines.push(" * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed."); lines.push(" */"); lines.push("{"); diff --git a/src/core/services/anki-jimaku-ipc-deps-runtime-service.test.ts b/src/core/services/anki-jimaku-ipc-deps-runtime-service.test.ts deleted file mode 100644 index c11dd9d..0000000 --- a/src/core/services/anki-jimaku-ipc-deps-runtime-service.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - AnkiJimakuIpcDepsRuntimeOptions, - createAnkiJimakuIpcDepsRuntimeService, -} from "./anki-jimaku-ipc-deps-runtime-service"; - -test("createAnkiJimakuIpcDepsRuntimeService returns passthrough runtime options", async () => { - const calls: string[] = []; - const options = { - patchAnkiConnectEnabled: () => calls.push("patch"), - getResolvedConfig: () => ({ ankiConnect: undefined }), - getRuntimeOptionsManager: () => null, - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getAnkiIntegration: () => null, - setAnkiIntegration: () => calls.push("set-integration"), - showDesktopNotification: () => calls.push("notify"), - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: false, - cancelled: true, - }), - broadcastRuntimeOptionsChanged: () => calls.push("broadcast"), - getFieldGroupingResolver: () => null, - setFieldGroupingResolver: () => calls.push("set-resolver"), - parseMediaInfo: () => ({ mediaPath: null, baseName: null, episode: null }), - getCurrentMediaPath: () => "/tmp/a.mp4", - jimakuFetchJson: async () => ({ ok: true, data: [] }), - getJimakuMaxEntryResults: () => 100, - getJimakuLanguagePreference: () => "prefer-japanese", - resolveJimakuApiKey: async () => "abc", - isRemoteMediaPath: () => false, - downloadToFile: async () => ({ ok: true, path: "/tmp/a.srt" }), - } as unknown as AnkiJimakuIpcDepsRuntimeOptions; - - const runtime = createAnkiJimakuIpcDepsRuntimeService(options); - - runtime.patchAnkiConnectEnabled(true); - runtime.broadcastRuntimeOptionsChanged(); - runtime.setFieldGroupingResolver(null); - - assert.deepEqual(calls, ["patch", "broadcast", "set-resolver"]); - assert.equal(runtime.getCurrentMediaPath(), "/tmp/a.mp4"); - assert.equal(runtime.getJimakuMaxEntryResults(), 100); - assert.equal(await runtime.resolveJimakuApiKey(), "abc"); -}); diff --git a/src/core/services/anki-jimaku-ipc-deps-runtime-service.ts b/src/core/services/anki-jimaku-ipc-deps-runtime-service.ts deleted file mode 100644 index 4176c71..0000000 --- a/src/core/services/anki-jimaku-ipc-deps-runtime-service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - AnkiJimakuIpcRuntimeOptions, -} from "./anki-jimaku-runtime-service"; - -export type AnkiJimakuIpcDepsRuntimeOptions = AnkiJimakuIpcRuntimeOptions; - -export function createAnkiJimakuIpcDepsRuntimeService( - options: AnkiJimakuIpcDepsRuntimeOptions, -): AnkiJimakuIpcRuntimeOptions { - return { - patchAnkiConnectEnabled: options.patchAnkiConnectEnabled, - getResolvedConfig: options.getResolvedConfig, - getRuntimeOptionsManager: options.getRuntimeOptionsManager, - getSubtitleTimingTracker: options.getSubtitleTimingTracker, - getMpvClient: options.getMpvClient, - getAnkiIntegration: options.getAnkiIntegration, - setAnkiIntegration: options.setAnkiIntegration, - showDesktopNotification: options.showDesktopNotification, - createFieldGroupingCallback: options.createFieldGroupingCallback, - broadcastRuntimeOptionsChanged: options.broadcastRuntimeOptionsChanged, - getFieldGroupingResolver: options.getFieldGroupingResolver, - setFieldGroupingResolver: options.setFieldGroupingResolver, - parseMediaInfo: options.parseMediaInfo, - getCurrentMediaPath: options.getCurrentMediaPath, - jimakuFetchJson: options.jimakuFetchJson, - getJimakuMaxEntryResults: options.getJimakuMaxEntryResults, - getJimakuLanguagePreference: options.getJimakuLanguagePreference, - resolveJimakuApiKey: options.resolveJimakuApiKey, - isRemoteMediaPath: options.isRemoteMediaPath, - downloadToFile: options.downloadToFile, - }; -} diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 9dacd8b..71d2e85 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -73,15 +73,12 @@ export { getOverlayWindowsRuntimeService, setOverlayDebugVisualizationEnabledRuntimeService, } from "./overlay-broadcast-runtime-service"; -export { createMpvIpcClientDepsRuntimeService } from "./mpv-client-deps-runtime-service"; export { createAppLifecycleDepsRuntimeService } from "./app-lifecycle-deps-runtime-service"; export { createCliCommandDepsRuntimeService } from "./cli-command-deps-runtime-service"; export { createIpcDepsRuntimeService } from "./ipc-deps-runtime-service"; -export { createAnkiJimakuIpcDepsRuntimeService } from "./anki-jimaku-ipc-deps-runtime-service"; export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-runtime-service"; export { createSubsyncRuntimeDepsService } from "./subsync-deps-runtime-service"; export { createNumericShortcutRuntimeService } from "./numeric-shortcut-runtime-service"; -export { createOverlayVisibilityFacadeDepsRuntimeService } from "./overlay-visibility-facade-deps-runtime-service"; export { createMpvCommandIpcDepsRuntimeService } from "./mpv-command-ipc-deps-runtime-service"; export { createRuntimeOptionsIpcDepsRuntimeService } from "./runtime-options-ipc-deps-runtime-service"; export { createTokenizerDepsRuntimeService } from "./tokenizer-deps-runtime-service"; @@ -90,26 +87,8 @@ export { createInvisibleOverlayVisibilityDepsRuntimeService, createOverlayWindowRuntimeDepsService, createVisibleOverlayVisibilityDepsRuntimeService, -} from "./overlay-runtime-deps-service"; -export { - createOverlayShortcutLifecycleDepsRuntimeService, - createOverlayShortcutRuntimeDepsService, -} from "./overlay-shortcut-runtime-deps-service"; -export { - createCopyCurrentSubtitleDepsRuntimeService, - createHandleMineSentenceDigitDepsRuntimeService, - createHandleMultiCopyDigitDepsRuntimeService, - createMarkLastCardAsAudioCardDepsRuntimeService, - createMineSentenceCardDepsRuntimeService, - createTriggerFieldGroupingDepsRuntimeService, - createUpdateLastCardFromClipboardDepsRuntimeService, -} from "./mining-runtime-deps-service"; -export { - createGlobalShortcutRegistrationDepsRuntimeService, - createSecondarySubtitleCycleDepsRuntimeService, - createYomitanSettingsWindowDepsRuntimeService, - runOverlayShortcutLocalFallbackRuntimeService, -} from "./shortcut-ui-runtime-deps-service"; +} from "./overlay-deps-runtime-service"; +export { runOverlayShortcutLocalFallbackRuntimeService } from "./shortcut-ui-deps-runtime-service"; export { createStartupLifecycleHooksRuntimeService } from "./startup-lifecycle-hooks-runtime-service"; export { createRuntimeOptionsManagerRuntimeService } from "./runtime-options-manager-runtime-service"; export { createAppLoggingRuntimeService } from "./app-logging-runtime-service"; @@ -122,3 +101,4 @@ export { runStartupBootstrapRuntimeService } from "./startup-bootstrap-runtime-s export { runSubsyncManualFromIpcRuntimeService, triggerSubsyncFromConfigRuntimeService } from "./subsync-runtime-service"; export { updateInvisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService } from "./overlay-visibility-service"; export { registerAnkiJimakuIpcRuntimeService } from "./anki-jimaku-runtime-service"; +export { createOverlayManagerService } from "./overlay-manager-service"; diff --git a/src/core/services/mining-runtime-deps-service.test.ts b/src/core/services/mining-runtime-deps-service.test.ts deleted file mode 100644 index 5b49637..0000000 --- a/src/core/services/mining-runtime-deps-service.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - createCopyCurrentSubtitleDepsRuntimeService, - createHandleMineSentenceDigitDepsRuntimeService, - createHandleMultiCopyDigitDepsRuntimeService, - createMarkLastCardAsAudioCardDepsRuntimeService, - createMineSentenceCardDepsRuntimeService, - createTriggerFieldGroupingDepsRuntimeService, - createUpdateLastCardFromClipboardDepsRuntimeService, -} from "./mining-runtime-deps-service"; - -test("mining runtime deps builders preserve references", () => { - const showMpvOsd = (_text: string) => {}; - const writeClipboardText = (_text: string) => {}; - const readClipboardText = () => "x"; - const logError = (_message: string, _err: unknown) => {}; - const subtitleTimingTracker = null; - const ankiIntegration = null; - const mpvClient = null; - - const multiCopy = createHandleMultiCopyDigitDepsRuntimeService({ - subtitleTimingTracker, - writeClipboardText, - showMpvOsd, - }); - const copyCurrent = createCopyCurrentSubtitleDepsRuntimeService({ - subtitleTimingTracker, - writeClipboardText, - showMpvOsd, - }); - const updateLast = createUpdateLastCardFromClipboardDepsRuntimeService({ - ankiIntegration, - readClipboardText, - showMpvOsd, - }); - const fieldGrouping = createTriggerFieldGroupingDepsRuntimeService({ - ankiIntegration, - showMpvOsd, - }); - const markAudio = createMarkLastCardAsAudioCardDepsRuntimeService({ - ankiIntegration, - showMpvOsd, - }); - const mineCard = createMineSentenceCardDepsRuntimeService({ - ankiIntegration, - mpvClient, - showMpvOsd, - }); - const mineDigit = createHandleMineSentenceDigitDepsRuntimeService({ - subtitleTimingTracker, - ankiIntegration, - getCurrentSecondarySubText: () => undefined, - showMpvOsd, - logError, - }); - - assert.equal(multiCopy.writeClipboardText, writeClipboardText); - assert.equal(copyCurrent.showMpvOsd, showMpvOsd); - assert.equal(updateLast.readClipboardText, readClipboardText); - assert.equal(fieldGrouping.ankiIntegration, ankiIntegration); - assert.equal(markAudio.showMpvOsd, showMpvOsd); - assert.equal(mineCard.mpvClient, mpvClient); - assert.equal(mineDigit.logError, logError); -}); diff --git a/src/core/services/mining-runtime-deps-service.ts b/src/core/services/mining-runtime-deps-service.ts deleted file mode 100644 index d33c3ec..0000000 --- a/src/core/services/mining-runtime-deps-service.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - copyCurrentSubtitleService, - handleMineSentenceDigitService, - handleMultiCopyDigitService, - markLastCardAsAudioCardService, - mineSentenceCardService, - triggerFieldGroupingService, - updateLastCardFromClipboardService, -} from "./mining-runtime-service"; - -export function createHandleMultiCopyDigitDepsRuntimeService( - options: { - subtitleTimingTracker: Parameters[1]["subtitleTimingTracker"]; - writeClipboardText: Parameters[1]["writeClipboardText"]; - showMpvOsd: Parameters[1]["showMpvOsd"]; - }, -): Parameters[1] { - return { - subtitleTimingTracker: options.subtitleTimingTracker, - writeClipboardText: options.writeClipboardText, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createCopyCurrentSubtitleDepsRuntimeService( - options: { - subtitleTimingTracker: Parameters[0]["subtitleTimingTracker"]; - writeClipboardText: Parameters[0]["writeClipboardText"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - subtitleTimingTracker: options.subtitleTimingTracker, - writeClipboardText: options.writeClipboardText, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createUpdateLastCardFromClipboardDepsRuntimeService( - options: { - ankiIntegration: Parameters[0]["ankiIntegration"]; - readClipboardText: Parameters[0]["readClipboardText"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - ankiIntegration: options.ankiIntegration, - readClipboardText: options.readClipboardText, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createTriggerFieldGroupingDepsRuntimeService( - options: { - ankiIntegration: Parameters[0]["ankiIntegration"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - ankiIntegration: options.ankiIntegration, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createMarkLastCardAsAudioCardDepsRuntimeService( - options: { - ankiIntegration: Parameters[0]["ankiIntegration"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - ankiIntegration: options.ankiIntegration, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createMineSentenceCardDepsRuntimeService( - options: { - ankiIntegration: Parameters[0]["ankiIntegration"]; - mpvClient: Parameters[0]["mpvClient"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - ankiIntegration: options.ankiIntegration, - mpvClient: options.mpvClient, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createHandleMineSentenceDigitDepsRuntimeService( - options: { - subtitleTimingTracker: Parameters[1]["subtitleTimingTracker"]; - ankiIntegration: Parameters[1]["ankiIntegration"]; - getCurrentSecondarySubText: Parameters[1]["getCurrentSecondarySubText"]; - showMpvOsd: Parameters[1]["showMpvOsd"]; - logError: Parameters[1]["logError"]; - }, -): Parameters[1] { - return { - subtitleTimingTracker: options.subtitleTimingTracker, - ankiIntegration: options.ankiIntegration, - getCurrentSecondarySubText: options.getCurrentSecondarySubText, - showMpvOsd: options.showMpvOsd, - logError: options.logError, - }; -} diff --git a/src/core/services/mpv-client-deps-runtime-service.test.ts b/src/core/services/mpv-client-deps-runtime-service.test.ts deleted file mode 100644 index 7076f21..0000000 --- a/src/core/services/mpv-client-deps-runtime-service.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createMpvIpcClientDepsRuntimeService } from "./mpv-client-deps-runtime-service"; - -test("createMpvIpcClientDepsRuntimeService returns passthrough dep object", async () => { - const marker = { - getResolvedConfig: () => ({ auto_start_overlay: false } as never), - autoStartOverlay: true, - setOverlayVisible: () => {}, - shouldBindVisibleOverlayToMpvSubVisibility: () => true, - isVisibleOverlayVisible: () => false, - getReconnectTimer: () => null, - setReconnectTimer: () => {}, - getCurrentSubText: () => "x", - setCurrentSubText: () => {}, - setCurrentSubAssText: () => {}, - getSubtitleTimingTracker: () => null, - subtitleWsBroadcast: () => {}, - getOverlayWindowsCount: () => 0, - tokenizeSubtitle: async () => ({ text: "x", tokens: [], mergedTokens: [] }), - broadcastToOverlayWindows: () => {}, - updateCurrentMediaPath: () => {}, - updateMpvSubtitleRenderMetrics: () => {}, - getMpvSubtitleRenderMetrics: () => ({ - subPos: 100, - subFontSize: 40, - subScale: 1, - subMarginY: 0, - subMarginX: 0, - subFont: "sans", - subSpacing: 0, - subBold: false, - subItalic: false, - subBorderSize: 0, - subShadowOffset: 0, - subAssOverride: "yes", - subScaleByWindow: true, - subUseMargins: true, - osdHeight: 720, - osdDimensions: null, - }), - setPreviousSecondarySubVisibility: () => {}, - showMpvOsd: () => {}, - }; - - const deps = createMpvIpcClientDepsRuntimeService(marker); - assert.equal(deps.autoStartOverlay, true); - assert.equal(deps.getCurrentSubText(), "x"); - assert.equal(deps.getOverlayWindowsCount(), 0); - assert.equal(deps.shouldBindVisibleOverlayToMpvSubVisibility(), true); -}); diff --git a/src/core/services/mpv-client-deps-runtime-service.ts b/src/core/services/mpv-client-deps-runtime-service.ts deleted file mode 100644 index 1fd8841..0000000 --- a/src/core/services/mpv-client-deps-runtime-service.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - MpvIpcClientDeps, -} from "./mpv-service"; -import { Config, MpvSubtitleRenderMetrics, SubtitleData } from "../../types"; - -interface SubtitleTimingTrackerLike { - recordSubtitle: (text: string, start: number, end: number) => void; -} - -export interface MpvClientDepsRuntimeOptions { - getResolvedConfig: () => Config; - autoStartOverlay: boolean; - setOverlayVisible: (visible: boolean) => void; - shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; - isVisibleOverlayVisible: () => boolean; - getReconnectTimer: () => ReturnType | null; - setReconnectTimer: (timer: ReturnType | null) => void; - getCurrentSubText: () => string; - setCurrentSubText: (text: string) => void; - setCurrentSubAssText: (text: string) => void; - getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; - subtitleWsBroadcast: (text: string) => void; - getOverlayWindowsCount: () => number; - tokenizeSubtitle: (text: string) => Promise; - broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; - updateCurrentMediaPath: (mediaPath: unknown) => void; - updateMpvSubtitleRenderMetrics: ( - patch: Partial, - ) => void; - getMpvSubtitleRenderMetrics: () => MpvSubtitleRenderMetrics; - setPreviousSecondarySubVisibility: (value: boolean | null) => void; - showMpvOsd: (text: string) => void; -} - -export function createMpvIpcClientDepsRuntimeService( - options: MpvClientDepsRuntimeOptions, -): MpvIpcClientDeps { - return { - getResolvedConfig: options.getResolvedConfig, - autoStartOverlay: options.autoStartOverlay, - setOverlayVisible: options.setOverlayVisible, - shouldBindVisibleOverlayToMpvSubVisibility: - options.shouldBindVisibleOverlayToMpvSubVisibility, - isVisibleOverlayVisible: options.isVisibleOverlayVisible, - getReconnectTimer: options.getReconnectTimer, - setReconnectTimer: options.setReconnectTimer, - getCurrentSubText: options.getCurrentSubText, - setCurrentSubText: options.setCurrentSubText, - setCurrentSubAssText: options.setCurrentSubAssText, - getSubtitleTimingTracker: options.getSubtitleTimingTracker, - subtitleWsBroadcast: options.subtitleWsBroadcast, - getOverlayWindowsCount: options.getOverlayWindowsCount, - tokenizeSubtitle: options.tokenizeSubtitle, - broadcastToOverlayWindows: options.broadcastToOverlayWindows, - updateCurrentMediaPath: options.updateCurrentMediaPath, - updateMpvSubtitleRenderMetrics: options.updateMpvSubtitleRenderMetrics, - getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics, - setPreviousSecondarySubVisibility: options.setPreviousSecondarySubVisibility, - showMpvOsd: options.showMpvOsd, - }; -} diff --git a/src/core/services/overlay-runtime-deps-service.test.ts b/src/core/services/overlay-deps-runtime-service.test.ts similarity index 98% rename from src/core/services/overlay-runtime-deps-service.test.ts rename to src/core/services/overlay-deps-runtime-service.test.ts index a28c24f..57672bb 100644 --- a/src/core/services/overlay-runtime-deps-service.test.ts +++ b/src/core/services/overlay-deps-runtime-service.test.ts @@ -5,7 +5,7 @@ import { createInvisibleOverlayVisibilityDepsRuntimeService, createOverlayWindowRuntimeDepsService, createVisibleOverlayVisibilityDepsRuntimeService, -} from "./overlay-runtime-deps-service"; +} from "./overlay-deps-runtime-service"; test("createOverlayWindowRuntimeDepsService maps runtime state providers", () => { let visible = true; diff --git a/src/core/services/overlay-runtime-deps-service.ts b/src/core/services/overlay-deps-runtime-service.ts similarity index 100% rename from src/core/services/overlay-runtime-deps-service.ts rename to src/core/services/overlay-deps-runtime-service.ts diff --git a/src/core/services/overlay-manager-service.test.ts b/src/core/services/overlay-manager-service.test.ts new file mode 100644 index 0000000..57565d4 --- /dev/null +++ b/src/core/services/overlay-manager-service.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createOverlayManagerService } from "./overlay-manager-service"; + +test("overlay manager initializes with empty windows and hidden overlays", () => { + const manager = createOverlayManagerService(); + assert.equal(manager.getMainWindow(), null); + assert.equal(manager.getInvisibleWindow(), null); + assert.equal(manager.getVisibleOverlayVisible(), false); + assert.equal(manager.getInvisibleOverlayVisible(), false); + assert.deepEqual(manager.getOverlayWindows(), []); +}); + +test("overlay manager stores window references and returns stable window order", () => { + const manager = createOverlayManagerService(); + const visibleWindow = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; + const invisibleWindow = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; + + manager.setMainWindow(visibleWindow); + manager.setInvisibleWindow(invisibleWindow); + + assert.equal(manager.getMainWindow(), visibleWindow); + assert.equal(manager.getInvisibleWindow(), invisibleWindow); + assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow]); +}); + +test("overlay manager excludes destroyed windows", () => { + const manager = createOverlayManagerService(); + manager.setMainWindow({ isDestroyed: () => true } as unknown as Electron.BrowserWindow); + manager.setInvisibleWindow({ isDestroyed: () => false } as unknown as Electron.BrowserWindow); + + assert.equal(manager.getOverlayWindows().length, 1); +}); + +test("overlay manager stores visibility state", () => { + const manager = createOverlayManagerService(); + + manager.setVisibleOverlayVisible(true); + manager.setInvisibleOverlayVisible(true); + assert.equal(manager.getVisibleOverlayVisible(), true); + assert.equal(manager.getInvisibleOverlayVisible(), true); +}); diff --git a/src/core/services/overlay-manager-service.ts b/src/core/services/overlay-manager-service.ts new file mode 100644 index 0000000..05344da --- /dev/null +++ b/src/core/services/overlay-manager-service.ts @@ -0,0 +1,49 @@ +import { BrowserWindow } from "electron"; + +export interface OverlayManagerService { + getMainWindow: () => BrowserWindow | null; + setMainWindow: (window: BrowserWindow | null) => void; + getInvisibleWindow: () => BrowserWindow | null; + setInvisibleWindow: (window: BrowserWindow | null) => void; + getVisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + getInvisibleOverlayVisible: () => boolean; + setInvisibleOverlayVisible: (visible: boolean) => void; + getOverlayWindows: () => BrowserWindow[]; +} + +export function createOverlayManagerService(): OverlayManagerService { + let mainWindow: BrowserWindow | null = null; + let invisibleWindow: BrowserWindow | null = null; + let visibleOverlayVisible = false; + let invisibleOverlayVisible = false; + + return { + getMainWindow: () => mainWindow, + setMainWindow: (window) => { + mainWindow = window; + }, + getInvisibleWindow: () => invisibleWindow, + setInvisibleWindow: (window) => { + invisibleWindow = window; + }, + getVisibleOverlayVisible: () => visibleOverlayVisible, + setVisibleOverlayVisible: (visible) => { + visibleOverlayVisible = visible; + }, + getInvisibleOverlayVisible: () => invisibleOverlayVisible, + setInvisibleOverlayVisible: (visible) => { + invisibleOverlayVisible = visible; + }, + getOverlayWindows: () => { + const windows: BrowserWindow[] = []; + if (mainWindow && !mainWindow.isDestroyed()) { + windows.push(mainWindow); + } + if (invisibleWindow && !invisibleWindow.isDestroyed()) { + windows.push(invisibleWindow); + } + return windows; + }, + }; +} diff --git a/src/core/services/overlay-shortcut-runtime-deps-service.test.ts b/src/core/services/overlay-shortcut-runtime-deps-service.test.ts deleted file mode 100644 index f3be2f1..0000000 --- a/src/core/services/overlay-shortcut-runtime-deps-service.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - createOverlayShortcutLifecycleDepsRuntimeService, - createOverlayShortcutRuntimeDepsService, -} from "./overlay-shortcut-runtime-deps-service"; - -test("createOverlayShortcutRuntimeDepsService returns callable runtime deps", async () => { - const calls: string[] = []; - const deps = createOverlayShortcutRuntimeDepsService({ - showMpvOsd: () => calls.push("showMpvOsd"), - openRuntimeOptions: () => calls.push("openRuntimeOptions"), - openJimaku: () => calls.push("openJimaku"), - markAudioCard: async () => { - calls.push("markAudioCard"); - }, - copySubtitleMultiple: () => calls.push("copySubtitleMultiple"), - copySubtitle: () => calls.push("copySubtitle"), - toggleSecondarySub: () => calls.push("toggleSecondarySub"), - updateLastCardFromClipboard: async () => { - calls.push("updateLastCardFromClipboard"); - }, - triggerFieldGrouping: async () => { - calls.push("triggerFieldGrouping"); - }, - triggerSubsync: async () => { - calls.push("triggerSubsync"); - }, - mineSentence: async () => { - calls.push("mineSentence"); - }, - mineSentenceMultiple: () => calls.push("mineSentenceMultiple"), - }); - - deps.copySubtitle(); - await deps.mineSentence(); - deps.mineSentenceMultiple(2); - - assert.deepEqual(calls, ["copySubtitle", "mineSentence", "mineSentenceMultiple"]); -}); - -test("createOverlayShortcutLifecycleDepsRuntimeService returns lifecycle passthrough", () => { - const deps = createOverlayShortcutLifecycleDepsRuntimeService({ - getConfiguredShortcuts: () => ({ actions: [] } as never), - getOverlayHandlers: () => ({} as never), - cancelPendingMultiCopy: () => {}, - cancelPendingMineSentenceMultiple: () => {}, - }); - - assert.ok(deps.getConfiguredShortcuts()); - assert.ok(deps.getOverlayHandlers()); -}); diff --git a/src/core/services/overlay-shortcut-runtime-deps-service.ts b/src/core/services/overlay-shortcut-runtime-deps-service.ts deleted file mode 100644 index 40699df..0000000 --- a/src/core/services/overlay-shortcut-runtime-deps-service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - OverlayShortcutLifecycleDeps, -} from "./overlay-shortcut-lifecycle-service"; -import { - OverlayShortcutRuntimeDeps, -} from "./overlay-shortcut-runtime-service"; - -export interface OverlayShortcutRuntimeDepsOptions { - showMpvOsd: (text: string) => void; - openRuntimeOptions: () => void; - openJimaku: () => void; - markAudioCard: () => Promise; - copySubtitleMultiple: (timeoutMs: number) => void; - copySubtitle: () => void; - toggleSecondarySub: () => void; - updateLastCardFromClipboard: () => Promise; - triggerFieldGrouping: () => Promise; - triggerSubsync: () => Promise; - mineSentence: () => Promise; - mineSentenceMultiple: (timeoutMs: number) => void; -} - -export interface OverlayShortcutLifecycleDepsOptions { - getConfiguredShortcuts: OverlayShortcutLifecycleDeps["getConfiguredShortcuts"]; - getOverlayHandlers: OverlayShortcutLifecycleDeps["getOverlayHandlers"]; - cancelPendingMultiCopy: () => void; - cancelPendingMineSentenceMultiple: () => void; -} - -export function createOverlayShortcutRuntimeDepsService( - options: OverlayShortcutRuntimeDepsOptions, -): OverlayShortcutRuntimeDeps { - return { - showMpvOsd: options.showMpvOsd, - openRuntimeOptions: options.openRuntimeOptions, - openJimaku: options.openJimaku, - markAudioCard: options.markAudioCard, - copySubtitleMultiple: options.copySubtitleMultiple, - copySubtitle: options.copySubtitle, - toggleSecondarySub: options.toggleSecondarySub, - updateLastCardFromClipboard: options.updateLastCardFromClipboard, - triggerFieldGrouping: options.triggerFieldGrouping, - triggerSubsync: options.triggerSubsync, - mineSentence: options.mineSentence, - mineSentenceMultiple: options.mineSentenceMultiple, - }; -} - -export function createOverlayShortcutLifecycleDepsRuntimeService( - options: OverlayShortcutLifecycleDepsOptions, -): OverlayShortcutLifecycleDeps { - return { - getConfiguredShortcuts: options.getConfiguredShortcuts, - getOverlayHandlers: options.getOverlayHandlers, - cancelPendingMultiCopy: options.cancelPendingMultiCopy, - cancelPendingMineSentenceMultiple: - options.cancelPendingMineSentenceMultiple, - }; -} diff --git a/src/core/services/overlay-visibility-facade-deps-runtime-service.test.ts b/src/core/services/overlay-visibility-facade-deps-runtime-service.test.ts deleted file mode 100644 index 78a1d99..0000000 --- a/src/core/services/overlay-visibility-facade-deps-runtime-service.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createOverlayVisibilityFacadeDepsRuntimeService } from "./overlay-visibility-facade-deps-runtime-service"; - -test("createOverlayVisibilityFacadeDepsRuntimeService returns working deps object", () => { - let visible = false; - let invisible = true; - let mpvSubVisible: boolean | null = null; - let syncCalls = 0; - - const deps = createOverlayVisibilityFacadeDepsRuntimeService({ - getVisibleOverlayVisible: () => visible, - getInvisibleOverlayVisible: () => invisible, - setVisibleOverlayVisibleState: (nextVisible) => { - visible = nextVisible; - }, - setInvisibleOverlayVisibleState: (nextVisible) => { - invisible = nextVisible; - }, - updateVisibleOverlayVisibility: () => {}, - updateInvisibleOverlayVisibility: () => {}, - syncInvisibleOverlayMousePassthrough: () => { - syncCalls += 1; - }, - shouldBindVisibleOverlayToMpvSubVisibility: () => true, - isMpvConnected: () => true, - setMpvSubVisibility: (nextVisible) => { - mpvSubVisible = nextVisible; - }, - }); - - assert.equal(deps.getVisibleOverlayVisible(), false); - assert.equal(deps.getInvisibleOverlayVisible(), true); - - deps.setVisibleOverlayVisibleState(true); - deps.setInvisibleOverlayVisibleState(false); - deps.syncInvisibleOverlayMousePassthrough(); - deps.setMpvSubVisibility(false); - - assert.equal(visible, true); - assert.equal(invisible, false); - assert.equal(syncCalls, 1); - assert.equal(mpvSubVisible, false); - assert.equal(deps.shouldBindVisibleOverlayToMpvSubVisibility(), true); - assert.equal(deps.isMpvConnected(), true); -}); diff --git a/src/core/services/overlay-visibility-facade-deps-runtime-service.ts b/src/core/services/overlay-visibility-facade-deps-runtime-service.ts deleted file mode 100644 index e138cfe..0000000 --- a/src/core/services/overlay-visibility-facade-deps-runtime-service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - OverlayVisibilityFacadeDeps, -} from "./overlay-visibility-facade-service"; - -export interface OverlayVisibilityFacadeDepsRuntimeOptions { - getVisibleOverlayVisible: () => boolean; - getInvisibleOverlayVisible: () => boolean; - setVisibleOverlayVisibleState: (nextVisible: boolean) => void; - setInvisibleOverlayVisibleState: (nextVisible: boolean) => void; - updateVisibleOverlayVisibility: () => void; - updateInvisibleOverlayVisibility: () => void; - syncInvisibleOverlayMousePassthrough: () => void; - shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; - isMpvConnected: () => boolean; - setMpvSubVisibility: (mpvSubVisible: boolean) => void; -} - -export function createOverlayVisibilityFacadeDepsRuntimeService( - options: OverlayVisibilityFacadeDepsRuntimeOptions, -): OverlayVisibilityFacadeDeps { - return { - getVisibleOverlayVisible: options.getVisibleOverlayVisible, - getInvisibleOverlayVisible: options.getInvisibleOverlayVisible, - setVisibleOverlayVisibleState: options.setVisibleOverlayVisibleState, - setInvisibleOverlayVisibleState: options.setInvisibleOverlayVisibleState, - updateVisibleOverlayVisibility: options.updateVisibleOverlayVisibility, - updateInvisibleOverlayVisibility: options.updateInvisibleOverlayVisibility, - syncInvisibleOverlayMousePassthrough: - options.syncInvisibleOverlayMousePassthrough, - shouldBindVisibleOverlayToMpvSubVisibility: - options.shouldBindVisibleOverlayToMpvSubVisibility, - isMpvConnected: options.isMpvConnected, - setMpvSubVisibility: options.setMpvSubVisibility, - }; -} diff --git a/src/core/services/shortcut-ui-runtime-deps-service.test.ts b/src/core/services/shortcut-ui-deps-runtime-service.test.ts similarity index 54% rename from src/core/services/shortcut-ui-runtime-deps-service.test.ts rename to src/core/services/shortcut-ui-deps-runtime-service.test.ts index 0f5e592..1e68496 100644 --- a/src/core/services/shortcut-ui-runtime-deps-service.test.ts +++ b/src/core/services/shortcut-ui-deps-runtime-service.test.ts @@ -1,35 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - createGlobalShortcutRegistrationDepsRuntimeService, - createSecondarySubtitleCycleDepsRuntimeService, - createYomitanSettingsWindowDepsRuntimeService, runOverlayShortcutLocalFallbackRuntimeService, -} from "./shortcut-ui-runtime-deps-service"; +} from "./shortcut-ui-deps-runtime-service"; function makeOptions() { return { - yomitanExt: null, - getYomitanSettingsWindow: () => null, - setYomitanSettingsWindow: () => {}, - - shortcuts: { - toggleVisibleOverlayGlobal: "Ctrl+Shift+O", - toggleInvisibleOverlayGlobal: "Ctrl+Alt+O", - }, - onToggleVisibleOverlay: () => {}, - onToggleInvisibleOverlay: () => {}, - onOpenYomitanSettings: () => {}, - isDev: false, - getMainWindow: () => null, - - getSecondarySubMode: () => "hover" as const, - setSecondarySubMode: () => {}, - getLastSecondarySubToggleAtMs: () => 0, - setLastSecondarySubToggleAtMs: () => {}, - broadcastSecondarySubMode: () => {}, - showMpvOsd: () => {}, - getConfiguredShortcuts: () => ({ toggleVisibleOverlayGlobal: null, toggleInvisibleOverlayGlobal: null, @@ -63,17 +39,6 @@ function makeOptions() { }; } -test("shortcut ui deps builders return expected adapters", () => { - const options = makeOptions(); - const yomitan = createYomitanSettingsWindowDepsRuntimeService(options); - const globalShortcuts = createGlobalShortcutRegistrationDepsRuntimeService(options); - const secondary = createSecondarySubtitleCycleDepsRuntimeService(options); - - assert.equal(yomitan.yomitanExt, null); - assert.equal(typeof globalShortcuts.onOpenYomitanSettings, "function"); - assert.equal(secondary.getSecondarySubMode(), "hover"); -}); - test("runOverlayShortcutLocalFallbackRuntimeService delegates and returns boolean", () => { const options = { ...makeOptions(), diff --git a/src/core/services/shortcut-ui-deps-runtime-service.ts b/src/core/services/shortcut-ui-deps-runtime-service.ts new file mode 100644 index 0000000..74d2742 --- /dev/null +++ b/src/core/services/shortcut-ui-deps-runtime-service.ts @@ -0,0 +1,24 @@ +import { ConfiguredShortcuts } from "../utils/shortcut-config"; +import { OverlayShortcutFallbackHandlers, runOverlayShortcutLocalFallback } from "./overlay-shortcut-fallback-runner"; + +export interface ShortcutUiRuntimeDepsOptions { + getConfiguredShortcuts: () => ConfiguredShortcuts; + getOverlayShortcutFallbackHandlers: () => OverlayShortcutFallbackHandlers; + shortcutMatcher: ( + input: Electron.Input, + accelerator: string, + allowWhenRegistered?: boolean, + ) => boolean; +} + +export function runOverlayShortcutLocalFallbackRuntimeService( + input: Electron.Input, + options: ShortcutUiRuntimeDepsOptions, +): boolean { + return runOverlayShortcutLocalFallback( + input, + options.getConfiguredShortcuts(), + options.shortcutMatcher, + options.getOverlayShortcutFallbackHandlers(), + ); +} diff --git a/src/core/services/shortcut-ui-runtime-deps-service.ts b/src/core/services/shortcut-ui-runtime-deps-service.ts deleted file mode 100644 index ae77c40..0000000 --- a/src/core/services/shortcut-ui-runtime-deps-service.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Extension } from "electron"; -import { SecondarySubMode } from "../../types"; -import { ConfiguredShortcuts } from "../utils/shortcut-config"; -import { CycleSecondarySubModeDeps } from "./secondary-subtitle-service"; -import { OverlayShortcutFallbackHandlers, runOverlayShortcutLocalFallback } from "./overlay-shortcut-fallback-runner"; -import { OpenYomitanSettingsWindowOptions } from "./yomitan-settings-service"; -import { RegisterGlobalShortcutsServiceOptions } from "./shortcut-service"; - -export interface ShortcutUiRuntimeDepsOptions { - yomitanExt: Extension | null; - getYomitanSettingsWindow: OpenYomitanSettingsWindowOptions["getExistingWindow"]; - setYomitanSettingsWindow: OpenYomitanSettingsWindowOptions["setWindow"]; - - shortcuts: RegisterGlobalShortcutsServiceOptions["shortcuts"]; - onToggleVisibleOverlay: () => void; - onToggleInvisibleOverlay: () => void; - onOpenYomitanSettings: () => void; - isDev: boolean; - getMainWindow: RegisterGlobalShortcutsServiceOptions["getMainWindow"]; - - getSecondarySubMode: () => SecondarySubMode; - setSecondarySubMode: (mode: SecondarySubMode) => void; - getLastSecondarySubToggleAtMs: () => number; - setLastSecondarySubToggleAtMs: (timestampMs: number) => void; - broadcastSecondarySubMode: (mode: SecondarySubMode) => void; - showMpvOsd: (text: string) => void; - - getConfiguredShortcuts: () => ConfiguredShortcuts; - getOverlayShortcutFallbackHandlers: () => OverlayShortcutFallbackHandlers; - shortcutMatcher: ( - input: Electron.Input, - accelerator: string, - allowWhenRegistered?: boolean, - ) => boolean; -} - -export function createYomitanSettingsWindowDepsRuntimeService( - options: ShortcutUiRuntimeDepsOptions, -): OpenYomitanSettingsWindowOptions { - return { - yomitanExt: options.yomitanExt, - getExistingWindow: options.getYomitanSettingsWindow, - setWindow: options.setYomitanSettingsWindow, - }; -} - -export function createGlobalShortcutRegistrationDepsRuntimeService( - options: ShortcutUiRuntimeDepsOptions, -): RegisterGlobalShortcutsServiceOptions { - return { - shortcuts: options.shortcuts, - onToggleVisibleOverlay: options.onToggleVisibleOverlay, - onToggleInvisibleOverlay: options.onToggleInvisibleOverlay, - onOpenYomitanSettings: options.onOpenYomitanSettings, - isDev: options.isDev, - getMainWindow: options.getMainWindow, - }; -} - -export function createSecondarySubtitleCycleDepsRuntimeService( - options: ShortcutUiRuntimeDepsOptions, -): CycleSecondarySubModeDeps { - return { - getSecondarySubMode: options.getSecondarySubMode, - setSecondarySubMode: options.setSecondarySubMode, - getLastSecondarySubToggleAtMs: options.getLastSecondarySubToggleAtMs, - setLastSecondarySubToggleAtMs: options.setLastSecondarySubToggleAtMs, - broadcastSecondarySubMode: options.broadcastSecondarySubMode, - showMpvOsd: options.showMpvOsd, - }; -} - -export function runOverlayShortcutLocalFallbackRuntimeService( - input: Electron.Input, - options: ShortcutUiRuntimeDepsOptions, -): boolean { - return runOverlayShortcutLocalFallback( - input, - options.getConfiguredShortcuts(), - options.shortcutMatcher, - options.getOverlayShortcutFallbackHandlers(), - ); -} diff --git a/src/core/services/startup-lifecycle-hooks-runtime-service.ts b/src/core/services/startup-lifecycle-hooks-runtime-service.ts index 75e4067..3a38b41 100644 --- a/src/core/services/startup-lifecycle-hooks-runtime-service.ts +++ b/src/core/services/startup-lifecycle-hooks-runtime-service.ts @@ -7,10 +7,6 @@ import { AppShutdownRuntimeDeps, runAppShutdownRuntimeService, } from "./app-shutdown-runtime-service"; -import { - createStartupAppReadyDepsRuntimeService, - createStartupAppShutdownDepsRuntimeService, -} from "./startup-lifecycle-runtime-deps-service"; type StartupLifecycleHookDeps = Pick< AppLifecycleDepsRuntimeOptions, @@ -29,14 +25,10 @@ export function createStartupLifecycleHooksRuntimeService( ): StartupLifecycleHookDeps { return { onReady: async () => { - await runAppReadyRuntimeService( - createStartupAppReadyDepsRuntimeService(options.appReadyDeps), - ); + await runAppReadyRuntimeService(options.appReadyDeps); }, onWillQuitCleanup: () => { - runAppShutdownRuntimeService( - createStartupAppShutdownDepsRuntimeService(options.appShutdownDeps), - ); + runAppShutdownRuntimeService(options.appShutdownDeps); }, shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate, restoreWindowsOnActivate: options.restoreWindowsOnActivate, diff --git a/src/core/services/startup-lifecycle-runtime-deps-service.test.ts b/src/core/services/startup-lifecycle-runtime-deps-service.test.ts deleted file mode 100644 index bd7ce7d..0000000 --- a/src/core/services/startup-lifecycle-runtime-deps-service.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - createStartupAppReadyDepsRuntimeService, - createStartupAppShutdownDepsRuntimeService, -} from "./startup-lifecycle-runtime-deps-service"; - -test("createStartupAppReadyDepsRuntimeService preserves runtime deps behavior", async () => { - const calls: string[] = []; - const deps = createStartupAppReadyDepsRuntimeService({ - loadSubtitlePosition: () => calls.push("loadSubtitlePosition"), - resolveKeybindings: () => calls.push("resolveKeybindings"), - createMpvClient: () => calls.push("createMpvClient"), - reloadConfig: () => calls.push("reloadConfig"), - getResolvedConfig: () => ({ - secondarySub: { defaultMode: "hover" }, - websocket: { enabled: "auto", port: 1234 }, - }), - getConfigWarnings: () => [], - logConfigWarning: () => {}, - initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"), - setSecondarySubMode: () => calls.push("setSecondarySubMode"), - defaultSecondarySubMode: "hover", - defaultWebsocketPort: 8765, - hasMpvWebsocketPlugin: () => true, - startSubtitleWebsocket: () => calls.push("startSubtitleWebsocket"), - log: () => calls.push("log"), - createMecabTokenizerAndCheck: async () => { - calls.push("createMecab"); - }, - createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"), - loadYomitanExtension: async () => { - calls.push("loadYomitan"); - }, - texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - initializeOverlayRuntime: () => calls.push("initOverlayRuntime"), - handleInitialArgs: () => calls.push("handleInitialArgs"), - }); - - deps.loadSubtitlePosition(); - await deps.createMecabTokenizerAndCheck(); - deps.handleInitialArgs(); - - assert.equal(deps.defaultWebsocketPort, 8765); - assert.equal(deps.defaultSecondarySubMode, "hover"); - assert.deepEqual(calls, ["loadSubtitlePosition", "createMecab", "handleInitialArgs"]); -}); - -test("createStartupAppShutdownDepsRuntimeService preserves shutdown handlers", () => { - const calls: string[] = []; - const deps = createStartupAppShutdownDepsRuntimeService({ - unregisterAllGlobalShortcuts: () => calls.push("unregisterAllGlobalShortcuts"), - stopSubtitleWebsocket: () => calls.push("stopSubtitleWebsocket"), - stopTexthookerService: () => calls.push("stopTexthookerService"), - destroyYomitanParserWindow: () => calls.push("destroyYomitanParserWindow"), - clearYomitanParserPromises: () => calls.push("clearYomitanParserPromises"), - stopWindowTracker: () => calls.push("stopWindowTracker"), - destroyMpvSocket: () => calls.push("destroyMpvSocket"), - clearReconnectTimer: () => calls.push("clearReconnectTimer"), - destroySubtitleTimingTracker: () => calls.push("destroySubtitleTimingTracker"), - destroyAnkiIntegration: () => calls.push("destroyAnkiIntegration"), - }); - - deps.stopSubtitleWebsocket(); - deps.clearReconnectTimer(); - deps.destroyAnkiIntegration(); - - assert.deepEqual(calls, [ - "stopSubtitleWebsocket", - "clearReconnectTimer", - "destroyAnkiIntegration", - ]); -}); diff --git a/src/core/services/startup-lifecycle-runtime-deps-service.ts b/src/core/services/startup-lifecycle-runtime-deps-service.ts deleted file mode 100644 index 3da81c3..0000000 --- a/src/core/services/startup-lifecycle-runtime-deps-service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - AppReadyRuntimeDeps, -} from "./app-ready-runtime-service"; -import { - AppShutdownRuntimeDeps, -} from "./app-shutdown-runtime-service"; - -export type StartupAppReadyDepsRuntimeOptions = AppReadyRuntimeDeps; -export type StartupAppShutdownDepsRuntimeOptions = AppShutdownRuntimeDeps; - -export function createStartupAppReadyDepsRuntimeService( - options: StartupAppReadyDepsRuntimeOptions, -): AppReadyRuntimeDeps { - return { - loadSubtitlePosition: options.loadSubtitlePosition, - resolveKeybindings: options.resolveKeybindings, - createMpvClient: options.createMpvClient, - reloadConfig: options.reloadConfig, - getResolvedConfig: options.getResolvedConfig, - getConfigWarnings: options.getConfigWarnings, - logConfigWarning: options.logConfigWarning, - initRuntimeOptionsManager: options.initRuntimeOptionsManager, - setSecondarySubMode: options.setSecondarySubMode, - defaultSecondarySubMode: options.defaultSecondarySubMode, - defaultWebsocketPort: options.defaultWebsocketPort, - hasMpvWebsocketPlugin: options.hasMpvWebsocketPlugin, - startSubtitleWebsocket: options.startSubtitleWebsocket, - log: options.log, - createMecabTokenizerAndCheck: options.createMecabTokenizerAndCheck, - createSubtitleTimingTracker: options.createSubtitleTimingTracker, - loadYomitanExtension: options.loadYomitanExtension, - texthookerOnlyMode: options.texthookerOnlyMode, - shouldAutoInitializeOverlayRuntimeFromConfig: - options.shouldAutoInitializeOverlayRuntimeFromConfig, - initializeOverlayRuntime: options.initializeOverlayRuntime, - handleInitialArgs: options.handleInitialArgs, - }; -} - -export function createStartupAppShutdownDepsRuntimeService( - options: StartupAppShutdownDepsRuntimeOptions, -): AppShutdownRuntimeDeps { - return { - unregisterAllGlobalShortcuts: options.unregisterAllGlobalShortcuts, - stopSubtitleWebsocket: options.stopSubtitleWebsocket, - stopTexthookerService: options.stopTexthookerService, - destroyYomitanParserWindow: options.destroyYomitanParserWindow, - clearYomitanParserPromises: options.clearYomitanParserPromises, - stopWindowTracker: options.stopWindowTracker, - destroyMpvSocket: options.destroyMpvSocket, - clearReconnectTimer: options.clearReconnectTimer, - destroySubtitleTimingTracker: options.destroySubtitleTimingTracker, - destroyAnkiIntegration: options.destroyAnkiIntegration, - }; -} diff --git a/src/main.ts b/src/main.ts index bfbe735..f47d932 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,6 @@ import { clipboard, shell, protocol, - screen, Extension, } from "electron"; @@ -40,18 +39,12 @@ protocol.registerSchemesAsPrivileged([ ]); import * as path from "path"; -import * as http from "http"; -import * as https from "https"; import * as os from "os"; import * as fs from "fs"; -import * as crypto from "crypto"; import { MecabTokenizer } from "./mecab-tokenizer"; import { BaseWindowTracker } from "./window-trackers"; import type { JimakuApiResponse, - JimakuDownloadResult, - JimakuMediaInfo, - JimakuConfig, JimakuLanguagePreference, SubtitleData, SubtitlePosition, @@ -78,16 +71,12 @@ import { getSubsyncConfig, } from "./subsync/utils"; import { - hasExplicitCommand, parseArgs, shouldStartApp, } from "./cli/args"; import type { CliArgs, CliCommandSource } from "./cli/args"; import { printHelp } from "./cli/help"; import { - asBoolean, - asFiniteNumber, - asString, enforceUnsupportedWaylandMode, forceX11Backend, generateDefaultConfigFile, @@ -104,48 +93,33 @@ import { broadcastRuntimeOptionsChangedRuntimeService, broadcastToOverlayWindowsRuntimeService, copyCurrentSubtitleService, - createAnkiJimakuIpcDepsRuntimeService, createAppLifecycleDepsRuntimeService, createAppLoggingRuntimeService, createCliCommandDepsRuntimeService, - createCopyCurrentSubtitleDepsRuntimeService, + createOverlayManagerService, createFieldGroupingOverlayRuntimeService, - createGlobalShortcutRegistrationDepsRuntimeService, - createHandleMineSentenceDigitDepsRuntimeService, - createHandleMultiCopyDigitDepsRuntimeService, createInitializeOverlayRuntimeDepsService, createInvisibleOverlayVisibilityDepsRuntimeService, createIpcDepsRuntimeService, - createMarkLastCardAsAudioCardDepsRuntimeService, createMecabTokenizerAndCheckRuntimeService, - createMineSentenceCardDepsRuntimeService, createMpvCommandIpcDepsRuntimeService, - createMpvIpcClientDepsRuntimeService, createNumericShortcutRuntimeService, - createOverlayShortcutLifecycleDepsRuntimeService, - createOverlayShortcutRuntimeDepsService, createOverlayShortcutRuntimeHandlers, - createOverlayVisibilityFacadeDepsRuntimeService, createOverlayWindowRuntimeDepsService, createOverlayWindowService, createRuntimeOptionsIpcDepsRuntimeService, createRuntimeOptionsManagerRuntimeService, - createSecondarySubtitleCycleDepsRuntimeService, createStartupLifecycleHooksRuntimeService, createSubsyncRuntimeDepsService, createSubtitleTimingTrackerRuntimeService, createTokenizerDepsRuntimeService, - createTriggerFieldGroupingDepsRuntimeService, - createUpdateLastCardFromClipboardDepsRuntimeService, createVisibleOverlayVisibilityDepsRuntimeService, - createYomitanSettingsWindowDepsRuntimeService, cycleSecondarySubModeService, enforceOverlayLayerOrderService, ensureOverlayWindowLevelService, getInitialInvisibleOverlayVisibilityService, getJimakuLanguagePreferenceService, getJimakuMaxEntryResultsService, - getOverlayWindowsRuntimeService, handleCliCommandService, handleMineSentenceDigitService, handleMpvCommandFromIpcService, @@ -154,7 +128,6 @@ import { hasMpvWebsocketPlugin, initializeOverlayRuntimeService, isAutoUpdateEnabledRuntimeService, - isGlobalShortcutRegisteredSafe, jimakuFetchJsonService, loadSubtitlePositionService, loadYomitanExtensionService, @@ -211,7 +184,41 @@ if (process.platform === "linux") { } const DEFAULT_TEXTHOOKER_PORT = 5174; -const CONFIG_DIR = path.join(os.homedir(), ".config", "SubMiner"); +function resolveConfigDir(): string { + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); + const baseDirs = Array.from( + new Set([ + xdgConfigHome || path.join(os.homedir(), ".config"), + path.join(os.homedir(), ".config"), + ]), + ); + const appNames = ["SubMiner", "subminer"]; + + for (const baseDir of baseDirs) { + for (const appName of appNames) { + const dir = path.join(baseDir, appName); + if ( + fs.existsSync(path.join(dir, "config.jsonc")) || + fs.existsSync(path.join(dir, "config.json")) + ) { + return dir; + } + } + } + + for (const baseDir of baseDirs) { + for (const appName of appNames) { + const dir = path.join(baseDir, appName); + if (fs.existsSync(dir)) { + return dir; + } + } + } + + return path.join(baseDirs[0], "SubMiner"); +} + +const CONFIG_DIR = resolveConfigDir(); const USER_DATA_PATH = CONFIG_DIR; const configService = new ConfigService(CONFIG_DIR); const isDev = @@ -239,8 +246,6 @@ process.on("SIGTERM", () => { app.quit(); }); -let mainWindow: BrowserWindow | null = null; -let invisibleWindow: BrowserWindow | null = null; let yomitanExt: Extension | null = null; let yomitanSettingsWindow: BrowserWindow | null = null; let yomitanParserWindow: BrowserWindow | null = null; @@ -250,8 +255,6 @@ let mpvClient: MpvIpcClient | null = null; let reconnectTimer: ReturnType | null = null; let currentSubText = ""; let currentSubAssText = ""; -let visibleOverlayVisible = false; -let invisibleOverlayVisible = false; let windowTracker: BaseWindowTracker | null = null; let subtitlePosition: SubtitlePosition | null = null; let currentMediaPath: string | null = null; @@ -292,12 +295,13 @@ let fieldGroupingResolver: ((choice: KikuFieldGroupingChoice) => void) | null = let runtimeOptionsManager: RuntimeOptionsManager | null = null; let trackerNotReadyWarningShown = false; let overlayDebugVisualizationEnabled = false; +const overlayManager = createOverlayManagerService(); type OverlayHostedModal = "runtime-options" | "subsync"; const restoreVisibleOverlayOnModalClose = new Set(); const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntimeService({ - getMainWindow: () => mainWindow, - getVisibleOverlayVisible: () => visibleOverlayVisible, - getInvisibleOverlayVisible: () => invisibleOverlayVisible, + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), getResolver: () => fieldGroupingResolver, @@ -315,7 +319,7 @@ const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, "subtitle-positions"); function getRuntimeOptionsState(): RuntimeOptionState[] { if (!runtimeOptionsManager) return []; return runtimeOptionsManager.listOptions(); } function getOverlayWindows(): BrowserWindow[] { - return getOverlayWindowsRuntimeService({ mainWindow, invisibleWindow }); + return overlayManager.getOverlayWindows(); } function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { @@ -492,13 +496,14 @@ const startupState = runStartupBootstrapRuntimeService({ createMpvClient: () => { mpvClient = new MpvIpcClient( mpvSocketPath, - createMpvIpcClientDepsRuntimeService({ + { getResolvedConfig: () => getResolvedConfig(), autoStartOverlay, setOverlayVisible: (visible) => setOverlayVisible(visible), shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(), - isVisibleOverlayVisible: () => visibleOverlayVisible, + isVisibleOverlayVisible: () => + overlayManager.getVisibleOverlayVisible(), getReconnectTimer: () => reconnectTimer, setReconnectTimer: (timer) => { reconnectTimer = timer; @@ -532,11 +537,12 @@ const startupState = runStartupBootstrapRuntimeService({ showMpvOsd: (text) => { showMpvOsd(text); }, - }), + }, ); }, reloadConfig: () => { configService.reloadConfig(); + appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); }, getResolvedConfig: () => getResolvedConfig(), getConfigWarnings: () => configService.getWarnings(), @@ -705,7 +711,7 @@ function handleCliCommand( }, app: { stop: () => app.quit(), - hasMainWindow: () => Boolean(mainWindow), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), }, getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, schedule: (fn, delayMs) => setTimeout(fn, delayMs), @@ -773,10 +779,10 @@ function ensureOverlayWindowLevel(window: BrowserWindow): void { function enforceOverlayLayerOrder(): void { enforceOverlayLayerOrderService({ - visibleOverlayVisible, - invisibleOverlayVisible, - mainWindow, - invisibleWindow, + visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), + invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(), + mainWindow: overlayManager.getMainWindow(), + invisibleWindow: overlayManager.getInvisibleWindow(), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), }); } @@ -810,23 +816,31 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow { onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), - getVisibleOverlayVisible: () => visibleOverlayVisible, - getInvisibleOverlayVisible: () => invisibleOverlayVisible, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), tryHandleOverlayShortcutLocalFallback: (input) => tryHandleOverlayShortcutLocalFallback(input), onWindowClosed: (windowKind) => { if (windowKind === "visible") { - mainWindow = null; + overlayManager.setMainWindow(null); } else { - invisibleWindow = null; + overlayManager.setInvisibleWindow(null); } }, }), ); } -function createMainWindow(): BrowserWindow { mainWindow = createOverlayWindow("visible"); return mainWindow; } -function createInvisibleWindow(): BrowserWindow { invisibleWindow = createOverlayWindow("invisible"); return invisibleWindow; } +function createMainWindow(): BrowserWindow { + const window = createOverlayWindow("visible"); + overlayManager.setMainWindow(window); + return window; +} +function createInvisibleWindow(): BrowserWindow { + const window = createOverlayWindow("invisible"); + overlayManager.setInvisibleWindow(window); + return window; +} function initializeOverlayRuntime(): void { if (overlayRuntimeInitialized) { @@ -849,8 +863,9 @@ function initializeOverlayRuntime(): void { updateOverlayBounds: (geometry) => { updateOverlayBounds(geometry); }, - isVisibleOverlayVisible: () => visibleOverlayVisible, - isInvisibleOverlayVisible: () => invisibleOverlayVisible, + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + isInvisibleOverlayVisible: () => + overlayManager.getInvisibleOverlayVisible(), updateVisibleOverlayVisibility: () => { updateVisibleOverlayVisibility(); }, @@ -875,35 +890,12 @@ function initializeOverlayRuntime(): void { createFieldGroupingCallback: () => createFieldGroupingCallback(), }), ); - invisibleOverlayVisible = result.invisibleOverlayVisible; + overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible); overlayRuntimeInitialized = true; } function getShortcutUiRuntimeDeps() { return { - yomitanExt, - getYomitanSettingsWindow: () => yomitanSettingsWindow, - setYomitanSettingsWindow: (window: BrowserWindow | null) => { - yomitanSettingsWindow = window; - }, - shortcuts: getConfiguredShortcuts(), - onToggleVisibleOverlay: () => toggleVisibleOverlay(), - onToggleInvisibleOverlay: () => toggleInvisibleOverlay(), - onOpenYomitanSettings: () => openYomitanSettings(), - isDev, - getMainWindow: () => mainWindow, - getSecondarySubMode: () => secondarySubMode, - setSecondarySubMode: (mode: SecondarySubMode) => { - secondarySubMode = mode; - }, - getLastSecondarySubToggleAtMs: () => lastSecondarySubToggleAtMs, - setLastSecondarySubToggleAtMs: (timestampMs: number) => { - lastSecondarySubToggleAtMs = timestampMs; - }, - broadcastSecondarySubMode: (mode: SecondarySubMode) => { - broadcastToOverlayWindows("secondary-subtitle:mode", mode); - }, - showMpvOsd: (text: string) => showMpvOsd(text), getConfiguredShortcuts: () => getConfiguredShortcuts(), getOverlayShortcutFallbackHandlers: () => getOverlayShortcutRuntimeHandlers().fallbackHandlers, @@ -913,12 +905,25 @@ function getShortcutUiRuntimeDeps() { function openYomitanSettings(): void { openYomitanSettingsWindow( - createYomitanSettingsWindowDepsRuntimeService(getShortcutUiRuntimeDeps()), + { + yomitanExt, + getExistingWindow: () => yomitanSettingsWindow, + setWindow: (window: BrowserWindow | null) => { + yomitanSettingsWindow = window; + }, + }, ); } function registerGlobalShortcuts(): void { registerGlobalShortcutsService( - createGlobalShortcutRegistrationDepsRuntimeService(getShortcutUiRuntimeDeps()), + { + shortcuts: getConfiguredShortcuts(), + onToggleVisibleOverlay: () => toggleVisibleOverlay(), + onToggleInvisibleOverlay: () => toggleInvisibleOverlay(), + onOpenYomitanSettings: () => openYomitanSettings(), + isDev, + getMainWindow: () => overlayManager.getMainWindow(), + }, ); } @@ -926,7 +931,7 @@ function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolve function getOverlayShortcutRuntimeHandlers() { return createOverlayShortcutRuntimeHandlers( - createOverlayShortcutRuntimeDepsService({ + { showMpvOsd: (text) => showMpvOsd(text), openRuntimeOptions: () => { openRuntimeOptionsPalette(); @@ -949,7 +954,7 @@ function getOverlayShortcutRuntimeHandlers() { mineSentenceMultiple: (timeoutMs) => { startPendingMineSentenceMultiple(timeoutMs); }, - }), + }, ); } @@ -962,7 +967,20 @@ function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean { function cycleSecondarySubMode(): void { cycleSecondarySubModeService( - createSecondarySubtitleCycleDepsRuntimeService(getShortcutUiRuntimeDeps()), + { + getSecondarySubMode: () => secondarySubMode, + setSecondarySubMode: (mode: SecondarySubMode) => { + secondarySubMode = mode; + }, + getLastSecondarySubToggleAtMs: () => lastSecondarySubToggleAtMs, + setLastSecondarySubToggleAtMs: (timestampMs: number) => { + lastSecondarySubToggleAtMs = timestampMs; + }, + broadcastSecondarySubMode: (mode: SecondarySubMode) => { + broadcastToOverlayWindows("secondary-subtitle:mode", mode); + }, + showMpvOsd: (text: string) => showMpvOsd(text), + }, ); } @@ -984,27 +1002,26 @@ const numericShortcutRuntime = createNumericShortcutRuntimeService({ }); const multiCopySession = numericShortcutRuntime.createSession(); const mineSentenceSession = numericShortcutRuntime.createSession(); -const overlayVisibilityFacadeDeps = - createOverlayVisibilityFacadeDepsRuntimeService({ - getVisibleOverlayVisible: () => visibleOverlayVisible, - getInvisibleOverlayVisible: () => invisibleOverlayVisible, - setVisibleOverlayVisibleState: (nextVisible: boolean) => { - visibleOverlayVisible = nextVisible; - }, - setInvisibleOverlayVisibleState: (nextVisible: boolean) => { - invisibleOverlayVisible = nextVisible; - }, - updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility(), - updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(), - syncInvisibleOverlayMousePassthrough: () => - syncInvisibleOverlayMousePassthrough(), - shouldBindVisibleOverlayToMpvSubVisibility: () => - shouldBindVisibleOverlayToMpvSubVisibility(), - isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), - setMpvSubVisibility: (mpvSubVisible: boolean) => { - setMpvSubVisibilityRuntimeService(mpvClient, mpvSubVisible); - }, - }); +const overlayVisibilityFacadeDeps = { + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + setVisibleOverlayVisibleState: (nextVisible: boolean) => { + overlayManager.setVisibleOverlayVisible(nextVisible); + }, + setInvisibleOverlayVisibleState: (nextVisible: boolean) => { + overlayManager.setInvisibleOverlayVisible(nextVisible); + }, + updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + syncInvisibleOverlayMousePassthrough(), + shouldBindVisibleOverlayToMpvSubVisibility: () => + shouldBindVisibleOverlayToMpvSubVisibility(), + isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), + setMpvSubVisibility: (mpvSubVisible: boolean) => { + setMpvSubVisibilityRuntimeService(mpvClient, mpvSubVisible); + }, +}; function getSubsyncRuntimeDeps() { return createSubsyncRuntimeDepsService({ @@ -1043,59 +1060,59 @@ function startPendingMultiCopy(timeoutMs: number): void { function handleMultiCopyDigit(count: number): void { handleMultiCopyDigitService( count, - createHandleMultiCopyDigitDepsRuntimeService({ + { subtitleTimingTracker, writeClipboardText: (text) => clipboard.writeText(text), showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } function copyCurrentSubtitle(): void { copyCurrentSubtitleService( - createCopyCurrentSubtitleDepsRuntimeService({ + { subtitleTimingTracker, writeClipboardText: (text) => clipboard.writeText(text), showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } async function updateLastCardFromClipboard(): Promise { await updateLastCardFromClipboardService( - createUpdateLastCardFromClipboardDepsRuntimeService({ + { ankiIntegration, readClipboardText: () => clipboard.readText(), showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } async function triggerFieldGrouping(): Promise { await triggerFieldGroupingService( - createTriggerFieldGroupingDepsRuntimeService({ + { ankiIntegration, showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } async function markLastCardAsAudioCard(): Promise { await markLastCardAsAudioCardService( - createMarkLastCardAsAudioCardDepsRuntimeService({ + { ankiIntegration, showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } async function mineSentenceCard(): Promise { await mineSentenceCardService( - createMineSentenceCardDepsRuntimeService({ + { ankiIntegration, mpvClient, showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } @@ -1118,7 +1135,7 @@ function startPendingMineSentenceMultiple(timeoutMs: number): void { function handleMineSentenceDigit(count: number): void { handleMineSentenceDigitService( count, - createHandleMineSentenceDigitDepsRuntimeService({ + { subtitleTimingTracker, ankiIntegration, getCurrentSecondarySubText: () => @@ -1127,7 +1144,7 @@ function handleMineSentenceDigit(count: number): void { logError: (message, err) => { console.error(message, err); }, - }), + }, ); } @@ -1139,12 +1156,12 @@ function registerOverlayShortcuts(): void { } function getOverlayShortcutLifecycleDeps() { - return createOverlayShortcutLifecycleDepsRuntimeService({ + return { getConfiguredShortcuts: () => getConfiguredShortcuts(), getOverlayHandlers: () => getOverlayShortcutRuntimeHandlers().overlayHandlers, cancelPendingMultiCopy: () => cancelPendingMultiCopy(), cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultiple(), - }); + }; } function unregisterOverlayShortcuts(): void { @@ -1173,8 +1190,8 @@ function refreshOverlayShortcuts(): void { function updateVisibleOverlayVisibility(): void { updateVisibleOverlayVisibilityService( createVisibleOverlayVisibilityDepsRuntimeService({ - getVisibleOverlayVisible: () => visibleOverlayVisible, - getMainWindow: () => mainWindow, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), getWindowTracker: () => windowTracker, getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown) => { @@ -1203,9 +1220,10 @@ function updateVisibleOverlayVisibility(): void { function updateInvisibleOverlayVisibility(): void { updateInvisibleOverlayVisibilityService( createInvisibleOverlayVisibilityDepsRuntimeService({ - getInvisibleWindow: () => invisibleWindow, - getVisibleOverlayVisible: () => visibleOverlayVisible, - getInvisibleOverlayVisible: () => invisibleOverlayVisible, + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => + overlayManager.getInvisibleOverlayVisible(), getWindowTracker: () => windowTracker, updateOverlayBounds: (geometry) => updateOverlayBounds(geometry), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), @@ -1217,13 +1235,17 @@ function updateInvisibleOverlayVisibility(): void { function syncInvisibleOverlayMousePassthrough(): void { syncInvisibleOverlayMousePassthroughService({ - hasInvisibleWindow: () => Boolean(invisibleWindow && !invisibleWindow.isDestroyed()), + hasInvisibleWindow: () => { + const invisibleWindow = overlayManager.getInvisibleWindow(); + return Boolean(invisibleWindow && !invisibleWindow.isDestroyed()); + }, setIgnoreMouseEvents: (ignore, extra) => { + const invisibleWindow = overlayManager.getInvisibleWindow(); if (!invisibleWindow || invisibleWindow.isDestroyed()) return; invisibleWindow.setIgnoreMouseEvents(ignore, extra); }, - visibleOverlayVisible, - invisibleOverlayVisible, + visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), + invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(), }); } @@ -1285,10 +1307,11 @@ const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDepsRuntimeService({ registerIpcHandlersService( createIpcDepsRuntimeService({ - getInvisibleWindow: () => invisibleWindow, - getMainWindow: () => mainWindow, - getVisibleOverlayVisibility: () => visibleOverlayVisible, - getInvisibleOverlayVisibility: () => invisibleOverlayVisible, + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisibility: () => + overlayManager.getInvisibleOverlayVisible(), onOverlayModalClosed: (modal) => handleOverlayModalClosed(modal as OverlayHostedModal), openYomitanSettings: () => openYomitanSettings(), @@ -1316,7 +1339,7 @@ registerIpcHandlersService( ); registerAnkiJimakuIpcRuntimeService( - createAnkiJimakuIpcDepsRuntimeService({ + { patchAnkiConnectEnabled: (enabled) => { configService.patchRawConfig({ ankiConnect: { enabled } }); }, @@ -1344,5 +1367,5 @@ registerAnkiJimakuIpcRuntimeService( isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), downloadToFile: (url, destPath, headers) => downloadToFile(url, destPath, headers), - }), + }, ); From 8343b42b8ee64b2e799eb53dfb2545c4011954b3 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 03:09:06 -0800 Subject: [PATCH 02/74] refactor: inline runtime adapters in main wiring --- src/core/services/index.ts | 6 +- src/core/services/mpv-service.ts | 6 +- src/main.ts | 199 ++++++++++++++++++------------- 3 files changed, 121 insertions(+), 90 deletions(-) diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 71d2e85..097866c 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -56,7 +56,11 @@ export { updateOverlayBoundsService, } from "./overlay-window-service"; export { initializeOverlayRuntimeService } from "./overlay-runtime-init-service"; -export { syncInvisibleOverlayMousePassthroughService } from "./overlay-visibility-runtime-service"; +export { + setInvisibleOverlayVisibleService, + setVisibleOverlayVisibleService, + syncInvisibleOverlayMousePassthroughService, +} from "./overlay-visibility-runtime-service"; export { setInvisibleOverlayVisibleRuntimeFacadeService, setVisibleOverlayVisibleRuntimeFacadeService, diff --git a/src/core/services/mpv-service.ts b/src/core/services/mpv-service.ts index 3c5c54c..5e52520 100644 --- a/src/core/services/mpv-service.ts +++ b/src/core/services/mpv-service.ts @@ -397,9 +397,9 @@ export class MpvIpcClient implements MpvClient { this.send({ command: ["set_property", "secondary-sid", match.id], }); - this.deps.showMpvOsd( - `Secondary subtitle: ${lang} (track ${match.id})`, - ); + // this.deps.showMpvOsd( + // `Secondary subtitle: ${lang} (track ${match.id})`, + // ); break; } } diff --git a/src/main.ts b/src/main.ts index f47d932..7389b51 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,12 +51,15 @@ import type { Keybinding, WindowGeometry, SecondarySubMode, + SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult, KikuFieldGroupingChoice, KikuMergePreviewRequest, KikuMergePreviewResponse, + RuntimeOptionId, RuntimeOptionState, + RuntimeOptionValue, MpvSubtitleRenderMetrics, } from "./types"; import { SubtitleTimingTracker } from "./subtitle-timing-tracker"; @@ -98,22 +101,14 @@ import { createCliCommandDepsRuntimeService, createOverlayManagerService, createFieldGroupingOverlayRuntimeService, - createInitializeOverlayRuntimeDepsService, - createInvisibleOverlayVisibilityDepsRuntimeService, createIpcDepsRuntimeService, createMecabTokenizerAndCheckRuntimeService, - createMpvCommandIpcDepsRuntimeService, createNumericShortcutRuntimeService, createOverlayShortcutRuntimeHandlers, - createOverlayWindowRuntimeDepsService, createOverlayWindowService, - createRuntimeOptionsIpcDepsRuntimeService, createRuntimeOptionsManagerRuntimeService, - createStartupLifecycleHooksRuntimeService, - createSubsyncRuntimeDepsService, createSubtitleTimingTrackerRuntimeService, createTokenizerDepsRuntimeService, - createVisibleOverlayVisibilityDepsRuntimeService, cycleSecondarySubModeService, enforceOverlayLayerOrderService, ensureOverlayWindowLevelService, @@ -148,10 +143,10 @@ import { runSubsyncManualFromIpcRuntimeService, saveSubtitlePositionService, sendMpvCommandRuntimeService, - setInvisibleOverlayVisibleRuntimeFacadeService, + setInvisibleOverlayVisibleService, setMpvSubVisibilityRuntimeService, setOverlayDebugVisualizationEnabledRuntimeService, - setVisibleOverlayVisibleRuntimeFacadeService, + setVisibleOverlayVisibleService, shouldAutoInitializeOverlayRuntimeFromConfigService, shouldBindVisibleOverlayToMpvSubVisibilityService, shortcutMatchesInputForLocalFallback, @@ -160,8 +155,6 @@ import { syncInvisibleOverlayMousePassthroughService, syncOverlayShortcutsRuntimeService, tokenizeSubtitleService, - toggleInvisibleOverlayRuntimeFacadeService, - toggleVisibleOverlayRuntimeFacadeService, triggerFieldGroupingService, triggerSubsyncFromConfigRuntimeService, unregisterOverlayShortcutsRuntimeService, @@ -171,6 +164,13 @@ import { updateOverlayBoundsService, updateVisibleOverlayVisibilityService, } from "./core/services"; +import { runAppReadyRuntimeService } from "./core/services/app-ready-runtime-service"; +import { runAppShutdownRuntimeService } from "./core/services/app-shutdown-runtime-service"; +import { + applyRuntimeOptionResultRuntimeService, + cycleRuntimeOptionFromIpcRuntimeService, + setRuntimeOptionFromIpcRuntimeService, +} from "./core/services/runtime-options-runtime-service"; import { ConfigService, DEFAULT_CONFIG, @@ -487,8 +487,8 @@ const startupState = runStartupBootstrapRuntimeService({ handleCliCommand: (nextArgs, source) => handleCliCommand(nextArgs, source), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), logNoRunningInstance: () => appLogger.logNoRunningInstance(), - ...createStartupLifecycleHooksRuntimeService({ - appReadyDeps: { + onReady: async () => { + await runAppReadyRuntimeService({ loadSubtitlePosition: () => loadSubtitlePosition(), resolveKeybindings: () => { keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); @@ -593,8 +593,10 @@ const startupState = runStartupBootstrapRuntimeService({ shouldAutoInitializeOverlayRuntimeFromConfig(), initializeOverlayRuntime: () => initializeOverlayRuntime(), handleInitialArgs: () => handleInitialArgs(), - }, - appShutdownDeps: { + }); + }, + onWillQuitCleanup: () => { + runAppShutdownRuntimeService({ unregisterAllGlobalShortcuts: () => { globalShortcut.unregisterAll(); }, @@ -639,16 +641,16 @@ const startupState = runStartupBootstrapRuntimeService({ ankiIntegration.destroy(); } }, - }, - shouldRestoreWindowsOnActivate: () => - overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, - restoreWindowsOnActivate: () => { - createMainWindow(); - createInvisibleWindow(); - updateVisibleOverlayVisibility(); - updateInvisibleOverlayVisibility(); - }, - }), + }); + }, + shouldRestoreWindowsOnActivate: () => + overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, + restoreWindowsOnActivate: () => { + createMainWindow(); + createInvisibleWindow(); + updateVisibleOverlayVisibility(); + updateInvisibleOverlayVisibility(); + }, })); }, }); @@ -809,15 +811,17 @@ async function loadYomitanExtension(): Promise { function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow { return createOverlayWindowService( kind, - createOverlayWindowRuntimeDepsService({ + { isDev, - getOverlayDebugVisualizationEnabled: () => overlayDebugVisualizationEnabled, + overlayDebugVisualizationEnabled, ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + isOverlayVisible: (windowKind) => + windowKind === "visible" + ? overlayManager.getVisibleOverlayVisible() + : overlayManager.getInvisibleOverlayVisible(), tryHandleOverlayShortcutLocalFallback: (input) => tryHandleOverlayShortcutLocalFallback(input), onWindowClosed: (windowKind) => { @@ -827,7 +831,7 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow { overlayManager.setInvisibleWindow(null); } }, - }), + }, ); } @@ -847,7 +851,7 @@ function initializeOverlayRuntime(): void { return; } const result = initializeOverlayRuntimeService( - createInitializeOverlayRuntimeDepsService({ + { backendOverride, getInitialInvisibleOverlayVisibility: () => getInitialInvisibleOverlayVisibility(), @@ -888,7 +892,7 @@ function initializeOverlayRuntime(): void { }, showDesktopNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), - }), + }, ); overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible); overlayRuntimeInitialized = true; @@ -1002,29 +1006,9 @@ const numericShortcutRuntime = createNumericShortcutRuntimeService({ }); const multiCopySession = numericShortcutRuntime.createSession(); const mineSentenceSession = numericShortcutRuntime.createSession(); -const overlayVisibilityFacadeDeps = { - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), - setVisibleOverlayVisibleState: (nextVisible: boolean) => { - overlayManager.setVisibleOverlayVisible(nextVisible); - }, - setInvisibleOverlayVisibleState: (nextVisible: boolean) => { - overlayManager.setInvisibleOverlayVisible(nextVisible); - }, - updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility(), - updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(), - syncInvisibleOverlayMousePassthrough: () => - syncInvisibleOverlayMousePassthrough(), - shouldBindVisibleOverlayToMpvSubVisibility: () => - shouldBindVisibleOverlayToMpvSubVisibility(), - isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), - setMpvSubVisibility: (mpvSubVisible: boolean) => { - setMpvSubVisibilityRuntimeService(mpvClient, mpvSubVisible); - }, -}; function getSubsyncRuntimeDeps() { - return createSubsyncRuntimeDepsService({ + return { getMpvClient: () => mpvClient, getResolvedSubsyncConfig: () => getSubsyncConfig(getResolvedConfig().subsync), isSubsyncInProgress: () => subsyncInProgress, @@ -1032,9 +1016,12 @@ function getSubsyncRuntimeDeps() { subsyncInProgress = inProgress; }, showMpvOsd: (text: string) => showMpvOsd(text), - sendToVisibleOverlay: (channel, payload, options) => - sendToVisibleOverlay(channel, payload, options), - }); + openManualPicker: (payload: SubsyncManualPayload) => { + sendToVisibleOverlay("subsync:open-manual", payload, { + restoreOnModalClose: "subsync", + }); + }, + }; } async function triggerSubsyncFromConfig(): Promise { @@ -1189,21 +1176,21 @@ function refreshOverlayShortcuts(): void { function updateVisibleOverlayVisibility(): void { updateVisibleOverlayVisibilityService( - createVisibleOverlayVisibilityDepsRuntimeService({ - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getMainWindow: () => overlayManager.getMainWindow(), - getWindowTracker: () => windowTracker, - getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown, + { + visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), + mainWindow: overlayManager.getMainWindow(), + windowTracker, + trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown) => { trackerNotReadyWarningShown = shown; }, - shouldBindVisibleOverlayToMpvSubVisibility: () => + shouldBindVisibleOverlayToMpvSubVisibility: shouldBindVisibleOverlayToMpvSubVisibility(), - getPreviousSecondarySubVisibility: () => previousSecondarySubVisibility, + previousSecondarySubVisibility, setPreviousSecondarySubVisibility: (value) => { previousSecondarySubVisibility = value; }, - isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), + mpvConnected: Boolean(mpvClient && mpvClient.connected), mpvSend: (payload) => { if (!mpvClient) return; mpvClient.send(payload); @@ -1213,23 +1200,22 @@ function updateVisibleOverlayVisibility(): void { ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(), syncOverlayShortcuts: () => syncOverlayShortcuts(), - }), + }, ); } function updateInvisibleOverlayVisibility(): void { updateInvisibleOverlayVisibilityService( - createInvisibleOverlayVisibilityDepsRuntimeService({ - getInvisibleWindow: () => overlayManager.getInvisibleWindow(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisible: () => - overlayManager.getInvisibleOverlayVisible(), - getWindowTracker: () => windowTracker, + { + invisibleWindow: overlayManager.getInvisibleWindow(), + visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), + invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(), + windowTracker, updateOverlayBounds: (geometry) => updateOverlayBounds(geometry), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(), syncOverlayShortcuts: () => syncOverlayShortcuts(), - }), + }, ); } @@ -1250,21 +1236,41 @@ function syncInvisibleOverlayMousePassthrough(): void { } function setVisibleOverlayVisible(visible: boolean): void { - setVisibleOverlayVisibleRuntimeFacadeService(visible, overlayVisibilityFacadeDeps); + setVisibleOverlayVisibleService({ + visible, + setVisibleOverlayVisibleState: (nextVisible) => { + overlayManager.setVisibleOverlayVisible(nextVisible); + }, + updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + syncInvisibleOverlayMousePassthrough(), + shouldBindVisibleOverlayToMpvSubVisibility: () => + shouldBindVisibleOverlayToMpvSubVisibility(), + isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), + setMpvSubVisibility: (mpvSubVisible) => { + setMpvSubVisibilityRuntimeService(mpvClient, mpvSubVisible); + }, + }); } function setInvisibleOverlayVisible(visible: boolean): void { - setInvisibleOverlayVisibleRuntimeFacadeService( + setInvisibleOverlayVisibleService({ visible, - overlayVisibilityFacadeDeps, - ); + setInvisibleOverlayVisibleState: (nextVisible) => { + overlayManager.setInvisibleOverlayVisible(nextVisible); + }, + updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + syncInvisibleOverlayMousePassthrough(), + }); } function toggleVisibleOverlay(): void { - toggleVisibleOverlayRuntimeFacadeService(overlayVisibilityFacadeDeps); + setVisibleOverlayVisible(!overlayManager.getVisibleOverlayVisible()); } function toggleInvisibleOverlay(): void { - toggleInvisibleOverlayRuntimeFacadeService(overlayVisibilityFacadeDeps); + setInvisibleOverlayVisible(!overlayManager.getInvisibleOverlayVisible()); } function setOverlayVisible(visible: boolean): void { setVisibleOverlayVisible(visible); } function toggleOverlay(): void { toggleVisibleOverlay(); } @@ -1279,18 +1285,27 @@ function handleOverlayModalClosed(modal: OverlayHostedModal): void { function handleMpvCommandFromIpc(command: (string | number)[]): void { handleMpvCommandFromIpcService( command, - createMpvCommandIpcDepsRuntimeService({ + { specialCommands: SPECIAL_COMMANDS, triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - getRuntimeOptionsManager: () => runtimeOptionsManager, + runtimeOptionsCycle: (id, direction) => { + if (!runtimeOptionsManager) { + return { ok: false, error: "Runtime options manager unavailable" }; + } + return applyRuntimeOptionResultRuntimeService( + runtimeOptionsManager.cycleOption(id, direction), + (text) => showMpvOsd(text), + ); + }, showMpvOsd: (text) => showMpvOsd(text), mpvReplaySubtitle: () => replayCurrentSubtitleRuntimeService(mpvClient), mpvPlayNextSubtitle: () => playNextSubtitleRuntimeService(mpvClient), mpvSendCommand: (rawCommand) => sendMpvCommandRuntimeService(mpvClient, rawCommand), isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), - }), + hasRuntimeOptionsManager: () => runtimeOptionsManager !== null, + }, ); } @@ -1300,10 +1315,22 @@ async function runSubsyncManualFromIpc( return runSubsyncManualFromIpcRuntimeService(request, getSubsyncRuntimeDeps()); } -const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDepsRuntimeService({ - getRuntimeOptionsManager: () => runtimeOptionsManager, - showMpvOsd: (text) => showMpvOsd(text), -}); +const runtimeOptionsIpcDeps = { + setRuntimeOption: (id: string, value: unknown) => + setRuntimeOptionFromIpcRuntimeService( + runtimeOptionsManager, + id as RuntimeOptionId, + value as RuntimeOptionValue, + (text) => showMpvOsd(text), + ), + cycleRuntimeOption: (id: string, direction: 1 | -1) => + cycleRuntimeOptionFromIpcRuntimeService( + runtimeOptionsManager, + id as RuntimeOptionId, + direction, + (text) => showMpvOsd(text), + ), +}; registerIpcHandlersService( createIpcDepsRuntimeService({ From 36085b6d1cf79c8e2dffd0db491c54bff2a27129 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 03:13:43 -0800 Subject: [PATCH 03/74] refactor: remove unused runtime adapter services --- package.json | 2 +- src/core/services/index.ts | 16 -- ...v-command-ipc-deps-runtime-service.test.ts | 28 --- .../mpv-command-ipc-deps-runtime-service.ts | 53 ------ .../overlay-deps-runtime-service.test.ts | 105 ----------- .../services/overlay-deps-runtime-service.ts | 174 ------------------ .../overlay-visibility-facade-service.test.ts | 63 ------- .../overlay-visibility-facade-service.ts | 64 ------- ...e-options-ipc-deps-runtime-service.test.ts | 28 --- ...untime-options-ipc-deps-runtime-service.ts | 38 ---- ...up-lifecycle-hooks-runtime-service.test.ts | 63 ------- ...startup-lifecycle-hooks-runtime-service.ts | 36 ---- .../subsync-deps-runtime-service.test.ts | 37 ---- .../services/subsync-deps-runtime-service.ts | 44 ----- 14 files changed, 1 insertion(+), 750 deletions(-) delete mode 100644 src/core/services/mpv-command-ipc-deps-runtime-service.test.ts delete mode 100644 src/core/services/mpv-command-ipc-deps-runtime-service.ts delete mode 100644 src/core/services/overlay-deps-runtime-service.test.ts delete mode 100644 src/core/services/overlay-deps-runtime-service.ts delete mode 100644 src/core/services/overlay-visibility-facade-service.test.ts delete mode 100644 src/core/services/overlay-visibility-facade-service.ts delete mode 100644 src/core/services/runtime-options-ipc-deps-runtime-service.test.ts delete mode 100644 src/core/services/runtime-options-ipc-deps-runtime-service.ts delete mode 100644 src/core/services/startup-lifecycle-hooks-runtime-service.test.ts delete mode 100644 src/core/services/startup-lifecycle-hooks-runtime-service.ts delete mode 100644 src/core/services/subsync-deps-runtime-service.test.ts delete mode 100644 src/core/services/subsync-deps-runtime-service.ts diff --git a/package.json b/package.json index c740579..c8696af 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "test:config": "pnpm run build && node --test dist/config/config.test.js", - "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/subsync-deps-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/mpv-command-ipc-deps-runtime-service.test.js dist/core/services/runtime-options-ipc-deps-runtime-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/overlay-deps-runtime-service.test.js dist/core/services/startup-lifecycle-hooks-runtime-service.test.js dist/core/services/shortcut-ui-deps-runtime-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-visibility-facade-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/app-ready-runtime-service.test.js dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-runtime-service.test.js", + "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/shortcut-ui-deps-runtime-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/app-ready-runtime-service.test.js dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-runtime-service.test.js", "test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", "generate:config-example": "pnpm run build && node dist/generate-config-example.js", "start": "pnpm run build && electron . --start", diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 097866c..c0be5b7 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -61,12 +61,6 @@ export { setVisibleOverlayVisibleService, syncInvisibleOverlayMousePassthroughService, } from "./overlay-visibility-runtime-service"; -export { - setInvisibleOverlayVisibleRuntimeFacadeService, - setVisibleOverlayVisibleRuntimeFacadeService, - toggleInvisibleOverlayRuntimeFacadeService, - toggleVisibleOverlayRuntimeFacadeService, -} from "./overlay-visibility-facade-service"; export { MpvIpcClient, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-service"; export { applyMpvSubtitleRenderMetricsPatchService } from "./mpv-render-metrics-service"; export { handleMpvCommandFromIpcService } from "./ipc-command-service"; @@ -81,19 +75,9 @@ export { createAppLifecycleDepsRuntimeService } from "./app-lifecycle-deps-runti export { createCliCommandDepsRuntimeService } from "./cli-command-deps-runtime-service"; export { createIpcDepsRuntimeService } from "./ipc-deps-runtime-service"; export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-runtime-service"; -export { createSubsyncRuntimeDepsService } from "./subsync-deps-runtime-service"; export { createNumericShortcutRuntimeService } from "./numeric-shortcut-runtime-service"; -export { createMpvCommandIpcDepsRuntimeService } from "./mpv-command-ipc-deps-runtime-service"; -export { createRuntimeOptionsIpcDepsRuntimeService } from "./runtime-options-ipc-deps-runtime-service"; export { createTokenizerDepsRuntimeService } from "./tokenizer-deps-runtime-service"; -export { - createInitializeOverlayRuntimeDepsService, - createInvisibleOverlayVisibilityDepsRuntimeService, - createOverlayWindowRuntimeDepsService, - createVisibleOverlayVisibilityDepsRuntimeService, -} from "./overlay-deps-runtime-service"; export { runOverlayShortcutLocalFallbackRuntimeService } from "./shortcut-ui-deps-runtime-service"; -export { createStartupLifecycleHooksRuntimeService } from "./startup-lifecycle-hooks-runtime-service"; export { createRuntimeOptionsManagerRuntimeService } from "./runtime-options-manager-runtime-service"; export { createAppLoggingRuntimeService } from "./app-logging-runtime-service"; export { diff --git a/src/core/services/mpv-command-ipc-deps-runtime-service.test.ts b/src/core/services/mpv-command-ipc-deps-runtime-service.test.ts deleted file mode 100644 index 1e5438b..0000000 --- a/src/core/services/mpv-command-ipc-deps-runtime-service.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { SPECIAL_COMMANDS } from "../../config"; -import { createMpvCommandIpcDepsRuntimeService } from "./mpv-command-ipc-deps-runtime-service"; - -test("createMpvCommandIpcDepsRuntimeService wires runtime-options cycle and manager availability", () => { - const osd: string[] = []; - const deps = createMpvCommandIpcDepsRuntimeService({ - specialCommands: SPECIAL_COMMANDS, - triggerSubsyncFromConfig: () => {}, - openRuntimeOptionsPalette: () => {}, - getRuntimeOptionsManager: () => ({ - cycleOption: () => ({ ok: true, osdMessage: "cycled" }), - }), - showMpvOsd: (text) => { - osd.push(text); - }, - mpvReplaySubtitle: () => {}, - mpvPlayNextSubtitle: () => {}, - mpvSendCommand: () => {}, - isMpvConnected: () => true, - }); - - const result = deps.runtimeOptionsCycle("subtitles.secondaryMode" as never, 1); - assert.equal(result.ok, true); - assert.equal(deps.hasRuntimeOptionsManager(), true); - assert.ok(osd.includes("cycled")); -}); diff --git a/src/core/services/mpv-command-ipc-deps-runtime-service.ts b/src/core/services/mpv-command-ipc-deps-runtime-service.ts deleted file mode 100644 index f117e56..0000000 --- a/src/core/services/mpv-command-ipc-deps-runtime-service.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - RuntimeOptionApplyResult, - RuntimeOptionId, -} from "../../types"; -import { - HandleMpvCommandFromIpcOptions, -} from "./ipc-command-service"; -import { applyRuntimeOptionResultRuntimeService } from "./runtime-options-runtime-service"; - -interface RuntimeOptionsManagerLike { - cycleOption: ( - id: RuntimeOptionId, - direction: 1 | -1, - ) => RuntimeOptionApplyResult; -} - -export interface MpvCommandIpcDepsRuntimeOptions { - specialCommands: HandleMpvCommandFromIpcOptions["specialCommands"]; - triggerSubsyncFromConfig: () => void; - openRuntimeOptionsPalette: () => void; - getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; - showMpvOsd: (text: string) => void; - mpvReplaySubtitle: () => void; - mpvPlayNextSubtitle: () => void; - mpvSendCommand: (command: (string | number)[]) => void; - isMpvConnected: () => boolean; -} - -export function createMpvCommandIpcDepsRuntimeService( - options: MpvCommandIpcDepsRuntimeOptions, -): HandleMpvCommandFromIpcOptions { - return { - specialCommands: options.specialCommands, - triggerSubsyncFromConfig: options.triggerSubsyncFromConfig, - openRuntimeOptionsPalette: options.openRuntimeOptionsPalette, - runtimeOptionsCycle: (id, direction) => { - const manager = options.getRuntimeOptionsManager(); - if (!manager) { - return { ok: false, error: "Runtime options manager unavailable" }; - } - return applyRuntimeOptionResultRuntimeService( - manager.cycleOption(id, direction), - options.showMpvOsd, - ); - }, - showMpvOsd: options.showMpvOsd, - mpvReplaySubtitle: options.mpvReplaySubtitle, - mpvPlayNextSubtitle: options.mpvPlayNextSubtitle, - mpvSendCommand: options.mpvSendCommand, - isMpvConnected: options.isMpvConnected, - hasRuntimeOptionsManager: () => options.getRuntimeOptionsManager() !== null, - }; -} diff --git a/src/core/services/overlay-deps-runtime-service.test.ts b/src/core/services/overlay-deps-runtime-service.test.ts deleted file mode 100644 index 57672bb..0000000 --- a/src/core/services/overlay-deps-runtime-service.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - createInitializeOverlayRuntimeDepsService, - createInvisibleOverlayVisibilityDepsRuntimeService, - createOverlayWindowRuntimeDepsService, - createVisibleOverlayVisibilityDepsRuntimeService, -} from "./overlay-deps-runtime-service"; - -test("createOverlayWindowRuntimeDepsService maps runtime state providers", () => { - let visible = true; - let invisible = false; - const deps = createOverlayWindowRuntimeDepsService({ - isDev: false, - getOverlayDebugVisualizationEnabled: () => true, - ensureOverlayWindowLevel: () => {}, - onRuntimeOptionsChanged: () => {}, - setOverlayDebugVisualizationEnabled: () => {}, - getVisibleOverlayVisible: () => visible, - getInvisibleOverlayVisible: () => invisible, - tryHandleOverlayShortcutLocalFallback: () => false, - onWindowClosed: () => {}, - }); - - assert.equal(deps.isOverlayVisible("visible"), true); - assert.equal(deps.isOverlayVisible("invisible"), false); - visible = false; - invisible = true; - assert.equal(deps.isOverlayVisible("visible"), false); - assert.equal(deps.isOverlayVisible("invisible"), true); -}); - -test("createInitializeOverlayRuntimeDepsService passes through overlay init deps", () => { - const windows: any[] = []; - const deps = createInitializeOverlayRuntimeDepsService({ - backendOverride: null, - getInitialInvisibleOverlayVisibility: () => true, - createMainWindow: () => {}, - createInvisibleWindow: () => {}, - registerGlobalShortcuts: () => {}, - updateOverlayBounds: () => {}, - isVisibleOverlayVisible: () => false, - isInvisibleOverlayVisible: () => true, - updateVisibleOverlayVisibility: () => {}, - updateInvisibleOverlayVisibility: () => {}, - getOverlayWindows: () => windows as never, - syncOverlayShortcuts: () => {}, - setWindowTracker: () => {}, - getResolvedConfig: () => ({ ankiConnect: undefined }), - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getRuntimeOptionsManager: () => null, - setAnkiIntegration: () => {}, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: true, - cancelled: true, - }), - }); - - assert.equal(deps.getInitialInvisibleOverlayVisibility(), true); - assert.equal(deps.getOverlayWindows().length, 0); -}); - -test("createVisibleOverlayVisibilityDepsRuntimeService snapshots runtime values", () => { - const deps = createVisibleOverlayVisibilityDepsRuntimeService({ - getVisibleOverlayVisible: () => true, - getMainWindow: () => null, - getWindowTracker: () => null, - getTrackerNotReadyWarningShown: () => false, - setTrackerNotReadyWarningShown: () => {}, - shouldBindVisibleOverlayToMpvSubVisibility: () => true, - getPreviousSecondarySubVisibility: () => null, - setPreviousSecondarySubVisibility: () => {}, - isMpvConnected: () => false, - mpvSend: () => {}, - secondarySubVisibilityRequestId: 123, - updateOverlayBounds: () => {}, - ensureOverlayWindowLevel: () => {}, - enforceOverlayLayerOrder: () => {}, - syncOverlayShortcuts: () => {}, - }); - - assert.equal(deps.visibleOverlayVisible, true); - assert.equal(deps.secondarySubVisibilityRequestId, 123); - assert.equal(deps.mpvConnected, false); -}); - -test("createInvisibleOverlayVisibilityDepsRuntimeService snapshots runtime values", () => { - const deps = createInvisibleOverlayVisibilityDepsRuntimeService({ - getInvisibleWindow: () => null, - getVisibleOverlayVisible: () => true, - getInvisibleOverlayVisible: () => false, - getWindowTracker: () => null, - updateOverlayBounds: () => {}, - ensureOverlayWindowLevel: () => {}, - enforceOverlayLayerOrder: () => {}, - syncOverlayShortcuts: () => {}, - }); - - assert.equal(deps.visibleOverlayVisible, true); - assert.equal(deps.invisibleOverlayVisible, false); -}); diff --git a/src/core/services/overlay-deps-runtime-service.ts b/src/core/services/overlay-deps-runtime-service.ts deleted file mode 100644 index d04716b..0000000 --- a/src/core/services/overlay-deps-runtime-service.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { BrowserWindow } from "electron"; -import { BaseWindowTracker } from "../../window-trackers"; -import { - AnkiConnectConfig, - KikuFieldGroupingChoice, - KikuFieldGroupingRequestData, - WindowGeometry, -} from "../../types"; -import { createOverlayWindowService } from "./overlay-window-service"; -import { initializeOverlayRuntimeService } from "./overlay-runtime-init-service"; -import { - updateInvisibleOverlayVisibilityService, - updateVisibleOverlayVisibilityService, -} from "./overlay-visibility-service"; - -interface MpvCommandSender { - command: Array; - request_id?: number; -} - -export interface OverlayWindowRuntimeDepsOptions { - isDev: boolean; - getOverlayDebugVisualizationEnabled: () => boolean; - ensureOverlayWindowLevel: (window: BrowserWindow) => void; - onRuntimeOptionsChanged: () => void; - setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; - getVisibleOverlayVisible: () => boolean; - getInvisibleOverlayVisible: () => boolean; - tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; - onWindowClosed: (kind: "visible" | "invisible") => void; -} - -export interface InitializeOverlayRuntimeDepsOptions { - backendOverride: string | null; - getInitialInvisibleOverlayVisibility: () => boolean; - createMainWindow: () => void; - createInvisibleWindow: () => void; - registerGlobalShortcuts: () => void; - updateOverlayBounds: (geometry: WindowGeometry) => void; - isVisibleOverlayVisible: () => boolean; - isInvisibleOverlayVisible: () => boolean; - updateVisibleOverlayVisibility: () => void; - updateInvisibleOverlayVisibility: () => void; - getOverlayWindows: () => BrowserWindow[]; - syncOverlayShortcuts: () => void; - setWindowTracker: (tracker: BaseWindowTracker | null) => void; - getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; - getSubtitleTimingTracker: () => unknown | null; - getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null; - getRuntimeOptionsManager: () => { - getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; - } | null; - setAnkiIntegration: (integration: unknown | null) => void; - showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; - createFieldGroupingCallback: () => ( - data: KikuFieldGroupingRequestData, - ) => Promise; -} - -export interface VisibleOverlayVisibilityDepsRuntimeOptions { - getVisibleOverlayVisible: () => boolean; - getMainWindow: () => BrowserWindow | null; - getWindowTracker: () => BaseWindowTracker | null; - getTrackerNotReadyWarningShown: () => boolean; - setTrackerNotReadyWarningShown: (shown: boolean) => void; - shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; - getPreviousSecondarySubVisibility: () => boolean | null; - setPreviousSecondarySubVisibility: (value: boolean | null) => void; - isMpvConnected: () => boolean; - mpvSend: (payload: MpvCommandSender) => void; - secondarySubVisibilityRequestId: number; - updateOverlayBounds: (geometry: WindowGeometry) => void; - ensureOverlayWindowLevel: (window: BrowserWindow) => void; - enforceOverlayLayerOrder: () => void; - syncOverlayShortcuts: () => void; -} - -export interface InvisibleOverlayVisibilityDepsRuntimeOptions { - getInvisibleWindow: () => BrowserWindow | null; - getVisibleOverlayVisible: () => boolean; - getInvisibleOverlayVisible: () => boolean; - getWindowTracker: () => BaseWindowTracker | null; - updateOverlayBounds: (geometry: WindowGeometry) => void; - ensureOverlayWindowLevel: (window: BrowserWindow) => void; - enforceOverlayLayerOrder: () => void; - syncOverlayShortcuts: () => void; -} - -export function createOverlayWindowRuntimeDepsService( - options: OverlayWindowRuntimeDepsOptions, -): Parameters[1] { - return { - isDev: options.isDev, - overlayDebugVisualizationEnabled: - options.getOverlayDebugVisualizationEnabled(), - ensureOverlayWindowLevel: options.ensureOverlayWindowLevel, - onRuntimeOptionsChanged: options.onRuntimeOptionsChanged, - setOverlayDebugVisualizationEnabled: - options.setOverlayDebugVisualizationEnabled, - isOverlayVisible: (kind) => - kind === "visible" - ? options.getVisibleOverlayVisible() - : options.getInvisibleOverlayVisible(), - tryHandleOverlayShortcutLocalFallback: - options.tryHandleOverlayShortcutLocalFallback, - onWindowClosed: options.onWindowClosed, - }; -} - -export function createInitializeOverlayRuntimeDepsService( - options: InitializeOverlayRuntimeDepsOptions, -): Parameters[0] { - return { - backendOverride: options.backendOverride, - getInitialInvisibleOverlayVisibility: - options.getInitialInvisibleOverlayVisibility, - createMainWindow: options.createMainWindow, - createInvisibleWindow: options.createInvisibleWindow, - registerGlobalShortcuts: options.registerGlobalShortcuts, - updateOverlayBounds: options.updateOverlayBounds, - isVisibleOverlayVisible: options.isVisibleOverlayVisible, - isInvisibleOverlayVisible: options.isInvisibleOverlayVisible, - updateVisibleOverlayVisibility: options.updateVisibleOverlayVisibility, - updateInvisibleOverlayVisibility: options.updateInvisibleOverlayVisibility, - getOverlayWindows: options.getOverlayWindows, - syncOverlayShortcuts: options.syncOverlayShortcuts, - setWindowTracker: options.setWindowTracker, - getResolvedConfig: options.getResolvedConfig, - getSubtitleTimingTracker: options.getSubtitleTimingTracker, - getMpvClient: options.getMpvClient, - getRuntimeOptionsManager: options.getRuntimeOptionsManager, - setAnkiIntegration: options.setAnkiIntegration, - showDesktopNotification: options.showDesktopNotification, - createFieldGroupingCallback: options.createFieldGroupingCallback, - }; -} - -export function createVisibleOverlayVisibilityDepsRuntimeService( - options: VisibleOverlayVisibilityDepsRuntimeOptions, -): Parameters[0] { - return { - visibleOverlayVisible: options.getVisibleOverlayVisible(), - mainWindow: options.getMainWindow(), - windowTracker: options.getWindowTracker(), - trackerNotReadyWarningShown: options.getTrackerNotReadyWarningShown(), - setTrackerNotReadyWarningShown: options.setTrackerNotReadyWarningShown, - shouldBindVisibleOverlayToMpvSubVisibility: - options.shouldBindVisibleOverlayToMpvSubVisibility(), - previousSecondarySubVisibility: options.getPreviousSecondarySubVisibility(), - setPreviousSecondarySubVisibility: options.setPreviousSecondarySubVisibility, - mpvConnected: options.isMpvConnected(), - mpvSend: options.mpvSend, - secondarySubVisibilityRequestId: options.secondarySubVisibilityRequestId, - updateOverlayBounds: options.updateOverlayBounds, - ensureOverlayWindowLevel: options.ensureOverlayWindowLevel, - enforceOverlayLayerOrder: options.enforceOverlayLayerOrder, - syncOverlayShortcuts: options.syncOverlayShortcuts, - }; -} - -export function createInvisibleOverlayVisibilityDepsRuntimeService( - options: InvisibleOverlayVisibilityDepsRuntimeOptions, -): Parameters[0] { - return { - invisibleWindow: options.getInvisibleWindow(), - visibleOverlayVisible: options.getVisibleOverlayVisible(), - invisibleOverlayVisible: options.getInvisibleOverlayVisible(), - windowTracker: options.getWindowTracker(), - updateOverlayBounds: options.updateOverlayBounds, - ensureOverlayWindowLevel: options.ensureOverlayWindowLevel, - enforceOverlayLayerOrder: options.enforceOverlayLayerOrder, - syncOverlayShortcuts: options.syncOverlayShortcuts, - }; -} diff --git a/src/core/services/overlay-visibility-facade-service.test.ts b/src/core/services/overlay-visibility-facade-service.test.ts deleted file mode 100644 index e234d2b..0000000 --- a/src/core/services/overlay-visibility-facade-service.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - OverlayVisibilityFacadeDeps, - setVisibleOverlayVisibleRuntimeFacadeService, - toggleInvisibleOverlayRuntimeFacadeService, - toggleVisibleOverlayRuntimeFacadeService, -} from "./overlay-visibility-facade-service"; - -function makeDeps(initialVisible = false, initialInvisible = false): { - deps: OverlayVisibilityFacadeDeps; - getState: () => { visible: boolean; invisible: boolean; mpvSubVisible: boolean | null }; -} { - let visible = initialVisible; - let invisible = initialInvisible; - let mpvSubVisible: boolean | null = null; - - const deps: OverlayVisibilityFacadeDeps = { - getVisibleOverlayVisible: () => visible, - getInvisibleOverlayVisible: () => invisible, - setVisibleOverlayVisibleState: (value) => { - visible = value; - }, - setInvisibleOverlayVisibleState: (value) => { - invisible = value; - }, - updateVisibleOverlayVisibility: () => {}, - updateInvisibleOverlayVisibility: () => {}, - syncInvisibleOverlayMousePassthrough: () => {}, - shouldBindVisibleOverlayToMpvSubVisibility: () => true, - isMpvConnected: () => true, - setMpvSubVisibility: (value) => { - mpvSubVisible = value; - }, - }; - - return { - deps, - getState: () => ({ visible, invisible, mpvSubVisible }), - }; -} - -test("setVisibleOverlayVisibleRuntimeFacadeService updates visible state and mpv subtitle visibility", () => { - const { deps, getState } = makeDeps(false, true); - setVisibleOverlayVisibleRuntimeFacadeService(true, deps); - assert.deepEqual(getState(), { - visible: true, - invisible: true, - mpvSubVisible: false, - }); -}); - -test("toggleVisibleOverlayRuntimeFacadeService flips visible overlay state", () => { - const { deps, getState } = makeDeps(false, false); - toggleVisibleOverlayRuntimeFacadeService(deps); - assert.equal(getState().visible, true); -}); - -test("toggleInvisibleOverlayRuntimeFacadeService flips invisible overlay state", () => { - const { deps, getState } = makeDeps(false, false); - toggleInvisibleOverlayRuntimeFacadeService(deps); - assert.equal(getState().invisible, true); -}); diff --git a/src/core/services/overlay-visibility-facade-service.ts b/src/core/services/overlay-visibility-facade-service.ts deleted file mode 100644 index 309d7e2..0000000 --- a/src/core/services/overlay-visibility-facade-service.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - setInvisibleOverlayVisibleService, - setVisibleOverlayVisibleService, -} from "./overlay-visibility-runtime-service"; - -export interface OverlayVisibilityFacadeDeps { - getVisibleOverlayVisible: () => boolean; - getInvisibleOverlayVisible: () => boolean; - setVisibleOverlayVisibleState: (visible: boolean) => void; - setInvisibleOverlayVisibleState: (visible: boolean) => void; - updateVisibleOverlayVisibility: () => void; - updateInvisibleOverlayVisibility: () => void; - syncInvisibleOverlayMousePassthrough: () => void; - shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; - isMpvConnected: () => boolean; - setMpvSubVisibility: (visible: boolean) => void; -} - -export function setVisibleOverlayVisibleRuntimeFacadeService( - visible: boolean, - deps: OverlayVisibilityFacadeDeps, -): void { - setVisibleOverlayVisibleService({ - visible, - setVisibleOverlayVisibleState: deps.setVisibleOverlayVisibleState, - updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility, - updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility, - syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough, - shouldBindVisibleOverlayToMpvSubVisibility: - deps.shouldBindVisibleOverlayToMpvSubVisibility, - isMpvConnected: deps.isMpvConnected, - setMpvSubVisibility: deps.setMpvSubVisibility, - }); -} - -export function setInvisibleOverlayVisibleRuntimeFacadeService( - visible: boolean, - deps: OverlayVisibilityFacadeDeps, -): void { - setInvisibleOverlayVisibleService({ - visible, - setInvisibleOverlayVisibleState: deps.setInvisibleOverlayVisibleState, - updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility, - syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough, - }); -} - -export function toggleVisibleOverlayRuntimeFacadeService( - deps: OverlayVisibilityFacadeDeps, -): void { - setVisibleOverlayVisibleRuntimeFacadeService( - !deps.getVisibleOverlayVisible(), - deps, - ); -} - -export function toggleInvisibleOverlayRuntimeFacadeService( - deps: OverlayVisibilityFacadeDeps, -): void { - setInvisibleOverlayVisibleRuntimeFacadeService( - !deps.getInvisibleOverlayVisible(), - deps, - ); -} diff --git a/src/core/services/runtime-options-ipc-deps-runtime-service.test.ts b/src/core/services/runtime-options-ipc-deps-runtime-service.test.ts deleted file mode 100644 index 77d6aa2..0000000 --- a/src/core/services/runtime-options-ipc-deps-runtime-service.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createRuntimeOptionsIpcDepsRuntimeService } from "./runtime-options-ipc-deps-runtime-service"; - -test("createRuntimeOptionsIpcDepsRuntimeService delegates set/cycle with osd", () => { - const osd: string[] = []; - const deps = createRuntimeOptionsIpcDepsRuntimeService({ - getRuntimeOptionsManager: () => ({ - setOptionValue: () => ({ ok: true, osdMessage: "set ok" }), - cycleOption: () => ({ ok: true, osdMessage: "cycle ok" }), - }), - showMpvOsd: (text) => { - osd.push(text); - }, - }); - - const setResult = deps.setRuntimeOption("subtitles.secondaryMode", "hidden") as { - ok: boolean; - }; - const cycleResult = deps.cycleRuntimeOption("subtitles.secondaryMode", 1) as { - ok: boolean; - }; - - assert.equal(setResult.ok, true); - assert.equal(cycleResult.ok, true); - assert.ok(osd.includes("set ok")); - assert.ok(osd.includes("cycle ok")); -}); diff --git a/src/core/services/runtime-options-ipc-deps-runtime-service.ts b/src/core/services/runtime-options-ipc-deps-runtime-service.ts deleted file mode 100644 index 55777cf..0000000 --- a/src/core/services/runtime-options-ipc-deps-runtime-service.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - RuntimeOptionId, - RuntimeOptionValue, -} from "../../types"; -import { - cycleRuntimeOptionFromIpcRuntimeService, - RuntimeOptionsManagerLike, - setRuntimeOptionFromIpcRuntimeService, -} from "./runtime-options-runtime-service"; - -export interface RuntimeOptionsIpcDepsRuntimeOptions { - getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; - showMpvOsd: (text: string) => void; -} - -export function createRuntimeOptionsIpcDepsRuntimeService( - options: RuntimeOptionsIpcDepsRuntimeOptions, -): { - setRuntimeOption: (id: string, value: unknown) => unknown; - cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; -} { - return { - setRuntimeOption: (id, value) => - setRuntimeOptionFromIpcRuntimeService( - options.getRuntimeOptionsManager(), - id as RuntimeOptionId, - value as RuntimeOptionValue, - options.showMpvOsd, - ), - cycleRuntimeOption: (id, direction) => - cycleRuntimeOptionFromIpcRuntimeService( - options.getRuntimeOptionsManager(), - id as RuntimeOptionId, - direction, - options.showMpvOsd, - ), - }; -} diff --git a/src/core/services/startup-lifecycle-hooks-runtime-service.test.ts b/src/core/services/startup-lifecycle-hooks-runtime-service.test.ts deleted file mode 100644 index 8911532..0000000 --- a/src/core/services/startup-lifecycle-hooks-runtime-service.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createStartupLifecycleHooksRuntimeService } from "./startup-lifecycle-hooks-runtime-service"; - -test("createStartupLifecycleHooksRuntimeService wires app-ready and app-shutdown handlers", async () => { - const calls: string[] = []; - const hooks = createStartupLifecycleHooksRuntimeService({ - appReadyDeps: { - loadSubtitlePosition: () => calls.push("loadSubtitlePosition"), - resolveKeybindings: () => calls.push("resolveKeybindings"), - createMpvClient: () => calls.push("createMpvClient"), - reloadConfig: () => calls.push("reloadConfig"), - getResolvedConfig: () => ({ - secondarySub: { defaultMode: "hover" }, - websocket: { enabled: "auto", port: 1234 }, - }), - getConfigWarnings: () => [], - logConfigWarning: () => calls.push("logConfigWarning"), - initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"), - setSecondarySubMode: () => calls.push("setSecondarySubMode"), - defaultSecondarySubMode: "hover", - defaultWebsocketPort: 8765, - hasMpvWebsocketPlugin: () => true, - startSubtitleWebsocket: () => calls.push("startSubtitleWebsocket"), - log: () => calls.push("log"), - createMecabTokenizerAndCheck: async () => { - calls.push("createMecabTokenizerAndCheck"); - }, - createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"), - loadYomitanExtension: async () => { - calls.push("loadYomitanExtension"); - }, - texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - initializeOverlayRuntime: () => calls.push("initializeOverlayRuntime"), - handleInitialArgs: () => calls.push("handleInitialArgs"), - }, - appShutdownDeps: { - unregisterAllGlobalShortcuts: () => calls.push("unregisterAllGlobalShortcuts"), - stopSubtitleWebsocket: () => calls.push("stopSubtitleWebsocket"), - stopTexthookerService: () => calls.push("stopTexthookerService"), - destroyYomitanParserWindow: () => calls.push("destroyYomitanParserWindow"), - clearYomitanParserPromises: () => calls.push("clearYomitanParserPromises"), - stopWindowTracker: () => calls.push("stopWindowTracker"), - destroyMpvSocket: () => calls.push("destroyMpvSocket"), - clearReconnectTimer: () => calls.push("clearReconnectTimer"), - destroySubtitleTimingTracker: () => calls.push("destroySubtitleTimingTracker"), - destroyAnkiIntegration: () => calls.push("destroyAnkiIntegration"), - }, - shouldRestoreWindowsOnActivate: () => true, - restoreWindowsOnActivate: () => calls.push("restoreWindowsOnActivate"), - }); - - await hooks.onReady(); - hooks.onWillQuitCleanup(); - assert.equal(hooks.shouldRestoreWindowsOnActivate(), true); - hooks.restoreWindowsOnActivate(); - - assert.ok(calls.includes("loadSubtitlePosition")); - assert.ok(calls.includes("handleInitialArgs")); - assert.ok(calls.includes("destroyAnkiIntegration")); - assert.ok(calls.includes("restoreWindowsOnActivate")); -}); diff --git a/src/core/services/startup-lifecycle-hooks-runtime-service.ts b/src/core/services/startup-lifecycle-hooks-runtime-service.ts deleted file mode 100644 index 3a38b41..0000000 --- a/src/core/services/startup-lifecycle-hooks-runtime-service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { AppLifecycleDepsRuntimeOptions } from "./app-lifecycle-deps-runtime-service"; -import { - AppReadyRuntimeDeps, - runAppReadyRuntimeService, -} from "./app-ready-runtime-service"; -import { - AppShutdownRuntimeDeps, - runAppShutdownRuntimeService, -} from "./app-shutdown-runtime-service"; - -type StartupLifecycleHookDeps = Pick< - AppLifecycleDepsRuntimeOptions, - "onReady" | "onWillQuitCleanup" | "shouldRestoreWindowsOnActivate" | "restoreWindowsOnActivate" ->; - -export interface StartupLifecycleHooksRuntimeOptions { - appReadyDeps: AppReadyRuntimeDeps; - appShutdownDeps: AppShutdownRuntimeDeps; - shouldRestoreWindowsOnActivate: () => boolean; - restoreWindowsOnActivate: () => void; -} - -export function createStartupLifecycleHooksRuntimeService( - options: StartupLifecycleHooksRuntimeOptions, -): StartupLifecycleHookDeps { - return { - onReady: async () => { - await runAppReadyRuntimeService(options.appReadyDeps); - }, - onWillQuitCleanup: () => { - runAppShutdownRuntimeService(options.appShutdownDeps); - }, - shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate, - restoreWindowsOnActivate: options.restoreWindowsOnActivate, - }; -} diff --git a/src/core/services/subsync-deps-runtime-service.test.ts b/src/core/services/subsync-deps-runtime-service.test.ts deleted file mode 100644 index d0c9200..0000000 --- a/src/core/services/subsync-deps-runtime-service.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createSubsyncRuntimeDepsService } from "./subsync-deps-runtime-service"; - -test("createSubsyncRuntimeDepsService opens manual picker via visible overlay", () => { - let inProgress = false; - const calls: Array<{ channel: string; payload?: unknown; restore?: string }> = []; - - const deps = createSubsyncRuntimeDepsService({ - getMpvClient: () => null, - getResolvedSubsyncConfig: () => ({ - defaultMode: "auto", - ffsubsyncPath: "/usr/bin/ffsubsync", - alassPath: "/usr/bin/alass", - ffmpegPath: "/usr/bin/ffmpeg", - }), - isSubsyncInProgress: () => inProgress, - setSubsyncInProgress: (next) => { - inProgress = next; - }, - showMpvOsd: () => {}, - sendToVisibleOverlay: (channel, payload, options) => { - calls.push({ channel, payload, restore: options?.restoreOnModalClose }); - return true; - }, - }); - - deps.setSubsyncInProgress(true); - deps.openManualPicker({ - sourceTracks: [{ id: 1, label: "Japanese Track" }], - }); - - assert.equal(deps.isSubsyncInProgress(), true); - assert.equal(calls.length, 1); - assert.equal(calls[0]?.channel, "subsync:open-manual"); - assert.equal(calls[0]?.restore, "subsync"); -}); diff --git a/src/core/services/subsync-deps-runtime-service.ts b/src/core/services/subsync-deps-runtime-service.ts deleted file mode 100644 index b9b171e..0000000 --- a/src/core/services/subsync-deps-runtime-service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - SubsyncManualPayload, -} from "../../types"; -import { - SubsyncRuntimeDeps, -} from "./subsync-runtime-service"; -import { SubsyncResolvedConfig } from "../../subsync/utils"; - -interface MpvClientLike { - connected: boolean; - currentAudioStreamIndex: number | null; - send: (payload: { command: (string | number)[] }) => void; - requestProperty: (name: string) => Promise; -} - -export interface SubsyncDepsRuntimeOptions { - getMpvClient: () => MpvClientLike | null; - getResolvedSubsyncConfig: () => SubsyncResolvedConfig; - isSubsyncInProgress: () => boolean; - setSubsyncInProgress: (inProgress: boolean) => void; - showMpvOsd: (text: string) => void; - sendToVisibleOverlay: ( - channel: string, - payload?: unknown, - options?: { restoreOnModalClose?: "runtime-options" | "subsync" }, - ) => boolean; -} - -export function createSubsyncRuntimeDepsService( - options: SubsyncDepsRuntimeOptions, -): SubsyncRuntimeDeps { - return { - getMpvClient: options.getMpvClient, - getResolvedSubsyncConfig: options.getResolvedSubsyncConfig, - isSubsyncInProgress: options.isSubsyncInProgress, - setSubsyncInProgress: options.setSubsyncInProgress, - showMpvOsd: options.showMpvOsd, - openManualPicker: (payload: SubsyncManualPayload) => { - options.sendToVisibleOverlay("subsync:open-manual", payload, { - restoreOnModalClose: "subsync", - }); - }, - }; -} From 5cc22e3f1b13a72ad3190b170e72e9edc08d5f55 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 03:26:31 -0800 Subject: [PATCH 04/74] Integrate invisible overlay renderer calibration from mac build --- src/renderer/index.html | 2 +- src/renderer/renderer.ts | 428 ++++++++++++++++++++++----------------- src/renderer/style.css | 10 +- 3 files changed, 252 insertions(+), 188 deletions(-) diff --git a/src/renderer/index.html b/src/renderer/index.html index 1ee7f27..2b8b287 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -22,7 +22,7 @@ SubMiner diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 09f3b38..76f2a76 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -31,19 +31,6 @@ interface SubtitleData { tokens: MergedToken[] | null; } -interface AssInlineOverrides { - fontFamily?: string; - fontSize?: number; - letterSpacing?: number; - scaleX?: number; - scaleY?: number; - bold?: boolean; - italic?: boolean; - borderSize?: number; - shadowOffset?: number; - alignment?: number; -} - interface MpvSubtitleRenderMetrics { subPos: number; subFontSize: number; @@ -349,6 +336,9 @@ const overlayLayer = : overlayLayerFromQuery; const isInvisibleLayer = overlayLayer === "invisible"; const isLinuxPlatform = navigator.platform.toLowerCase().includes("linux"); +const isMacOSPlatform = + navigator.platform.toLowerCase().includes("mac") || + /mac/i.test(navigator.userAgent); // Linux passthrough forwarding is not reliable for this overlay; keep pointer // routing local so hover lookup, drag-reposition, and key handling remain usable. const shouldToggleMouseIgnore = !isLinuxPlatform; @@ -403,7 +393,13 @@ const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = { let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = { ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, }; -let currentSubtitleAss = ""; +let currentInvisibleSubtitleLineCount = 1; +let lastHoverSelectionKey = ""; +let lastHoverSelectionNode: Text | null = null; +const wordSegmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "word" }) + : null; function isAnySettingsModalOpen(): boolean { return runtimeOptionsModalOpen || subsyncModalOpen || kikuModalOpen; @@ -502,6 +498,8 @@ function renderPlainTextPreserveLineBreaks(text: string): void { function renderSubtitle(data: SubtitleData | string): void { subtitleRoot.innerHTML = ""; + lastHoverSelectionKey = ""; + lastHoverSelectionNode = null; let text: string; let tokens: MergedToken[] | null; @@ -521,8 +519,13 @@ function renderSubtitle(data: SubtitleData | string): void { } if (isInvisibleLayer) { - // Keep natural kerning/shaping for accurate hitbox alignment with mpv/libass. - renderPlainTextPreserveLineBreaks(normalizeSubtitle(text, false)); + // Keep natural kerning/shaping for accurate hitbox alignment with mpv. + const normalizedInvisible = normalizeSubtitle(text, false); + currentInvisibleSubtitleLineCount = Math.max( + 1, + normalizedInvisible.split("\n").length, + ); + renderPlainTextPreserveLineBreaks(normalizedInvisible); return; } @@ -741,101 +744,6 @@ function sanitizeMpvSubtitleRenderMetrics( }; } -function getLastMatch(text: string, pattern: RegExp): string | undefined { - let last: string | undefined; - let match: RegExpExecArray | null; - // eslint-disable-next-line no-cond-assign - while ((match = pattern.exec(text)) !== null) { - last = match[1]; - } - return last; -} - -function parseAssInlineOverrides(assText: string): AssInlineOverrides { - if (!assText) return {}; - - const result: AssInlineOverrides = {}; - - const fontFamily = getLastMatch(assText, /\\fn([^\\}]+)/g); - if (fontFamily && fontFamily.trim()) { - result.fontFamily = fontFamily.trim(); - } - - const fontSize = getLastMatch(assText, /\\fs(-?\d+(?:\.\d+)?)/g); - if (fontSize) { - const parsed = Number.parseFloat(fontSize); - if (Number.isFinite(parsed) && parsed > 0) { - result.fontSize = parsed; - } - } - - const letterSpacing = getLastMatch(assText, /\\fsp(-?\d+(?:\.\d+)?)/g); - if (letterSpacing) { - const parsed = Number.parseFloat(letterSpacing); - if (Number.isFinite(parsed)) { - result.letterSpacing = parsed; - } - } - - const scaleX = getLastMatch(assText, /\\fscx(-?\d+(?:\.\d+)?)/g); - if (scaleX) { - const parsed = Number.parseFloat(scaleX); - if (Number.isFinite(parsed) && parsed > 0) { - result.scaleX = parsed / 100; - } - } - - const scaleY = getLastMatch(assText, /\\fscy(-?\d+(?:\.\d+)?)/g); - if (scaleY) { - const parsed = Number.parseFloat(scaleY); - if (Number.isFinite(parsed) && parsed > 0) { - result.scaleY = parsed / 100; - } - } - - const bold = getLastMatch(assText, /\\b(-?\d+)/g); - if (bold) { - const parsed = Number.parseInt(bold, 10); - if (!Number.isNaN(parsed)) { - result.bold = parsed !== 0; - } - } - - const italic = getLastMatch(assText, /\\i(-?\d+)/g); - if (italic) { - const parsed = Number.parseInt(italic, 10); - if (!Number.isNaN(parsed)) { - result.italic = parsed !== 0; - } - } - - const borderSize = getLastMatch(assText, /\\bord(-?\d+(?:\.\d+)?)/g); - if (borderSize) { - const parsed = Number.parseFloat(borderSize); - if (Number.isFinite(parsed) && parsed >= 0) { - result.borderSize = parsed; - } - } - - const shadowOffset = getLastMatch(assText, /\\shad(-?\d+(?:\.\d+)?)/g); - if (shadowOffset) { - const parsed = Number.parseFloat(shadowOffset); - if (Number.isFinite(parsed) && parsed >= 0) { - result.shadowOffset = parsed; - } - } - - const alignment = getLastMatch(assText, /\\an(\d)/g); - if (alignment) { - const parsed = Number.parseInt(alignment, 10); - if (parsed >= 1 && parsed <= 9) { - result.alignment = parsed; - } - } - - return result; -} - function applyInvisibleSubtitleLayoutFromMpvMetrics( metrics: Partial | null | undefined, source: string, @@ -843,52 +751,50 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( mpvSubtitleRenderMetrics = sanitizeMpvSubtitleRenderMetrics(metrics); const dims = mpvSubtitleRenderMetrics.osdDimensions; - const renderAreaHeight = dims?.h ?? window.innerHeight; - const renderAreaWidth = dims?.w ?? window.innerWidth; - const videoLeftInset = dims?.ml ?? 0; - const videoRightInset = dims?.mr ?? 0; - const videoTopInset = dims?.mt ?? 0; - const videoBottomInset = dims?.mb ?? 0; + const dpr = window.devicePixelRatio || 1; + // On macOS, mpv osd-dimensions can be reported in either physical pixels or + // point-like units. Infer the osd->CSS scale from current viewport ratio. + const osdToCssScale = + isMacOSPlatform && dims + ? (() => { + const ratios = [ + dims.w / Math.max(1, window.innerWidth), + dims.h / Math.max(1, window.innerHeight), + ].filter((value) => Number.isFinite(value) && value > 0); + const avgRatio = + ratios.length > 0 + ? ratios.reduce((sum, value) => sum + value, 0) / ratios.length + : dpr; + return avgRatio > 1.25 ? avgRatio : 1; + })() + : dpr; + const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight; + const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth; + const videoLeftInset = dims ? dims.ml / osdToCssScale : 0; + const videoRightInset = dims ? dims.mr / osdToCssScale : 0; + const videoTopInset = dims ? dims.mt / osdToCssScale : 0; + const videoBottomInset = dims ? dims.mb / osdToCssScale : 0; const anchorToVideoArea = !mpvSubtitleRenderMetrics.subUseMargins; const leftInset = anchorToVideoArea ? videoLeftInset : 0; const rightInset = anchorToVideoArea ? videoRightInset : 0; const topInset = anchorToVideoArea ? videoTopInset : 0; const bottomInset = anchorToVideoArea ? videoBottomInset : 0; - // Match mpv subtitle sizing from scaled pixels in mpv's OSD space. - const osdReferenceHeight = mpvSubtitleRenderMetrics.subScaleByWindow - ? mpvSubtitleRenderMetrics.osdHeight - : 720; - const pxPerScaledPixel = Math.max(0.1, renderAreaHeight / osdReferenceHeight); + const videoHeight = renderAreaHeight - videoTopInset - videoBottomInset; + const scaleRefHeight = mpvSubtitleRenderMetrics.subScaleByWindow + ? renderAreaHeight + : videoHeight; + const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720); const computedFontSize = mpvSubtitleRenderMetrics.subFontSize * mpvSubtitleRenderMetrics.subScale * - pxPerScaledPixel; - const rawAssOverrides = parseAssInlineOverrides(currentSubtitleAss); + (isLinuxPlatform ? 1 : pxPerScaledPixel); - // When sub-ass-override is "yes" (default), "force", or "strip", mpv ignores - // ASS inline style tags (\fn, \fs, \b, \i, \bord, \shad, \fsp, \fscx, \fscy) - // and uses its own sub-* settings instead. Only positioning tags like \an and - // \pos are always respected. Since sub-text-ass returns the raw ASS text - // regardless of this setting, we must skip style overrides when mpv is - // overriding them. - const assOverrideMode = mpvSubtitleRenderMetrics.subAssOverride; - const mpvOverridesAssStyles = - assOverrideMode === "yes" || - assOverrideMode === "force" || - assOverrideMode === "strip"; - const assOverrides: AssInlineOverrides = mpvOverridesAssStyles - ? {} - : rawAssOverrides; - - const effectiveFontSize = - assOverrides.fontSize && assOverrides.fontSize > 0 - ? assOverrides.fontSize * pxPerScaledPixel - : computedFontSize; + const macOsFontCompensation = isMacOSPlatform ? 0.87 : 1; + const effectiveFontSize = computedFontSize * macOsFontCompensation; applySubtitleFontSize(effectiveFontSize); - // \an is a positioning tag — always respected regardless of sub-ass-override - const alignment = rawAssOverrides.alignment ?? 2; + const alignment = 2; const hAlign = ((alignment - 1) % 3) as 0 | 1 | 2; // 0=left, 1=center, 2=right const vAlign = Math.floor((alignment - 1) / 3) as 0 | 1 | 2; // 0=bottom, 1=middle, 2=top @@ -902,12 +808,19 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( renderAreaWidth - leftInset - rightInset - Math.round(marginX * 2), ); + const effectiveBorderSize = mpvSubtitleRenderMetrics.subBorderSize * pxPerScaledPixel; + document.documentElement.style.setProperty( + "--sub-border-size", + `${effectiveBorderSize}px`, + ); + subtitleContainer.style.position = "absolute"; subtitleContainer.style.maxWidth = `${horizontalAvailable}px`; subtitleContainer.style.width = `${horizontalAvailable}px`; subtitleContainer.style.padding = "0"; subtitleContainer.style.background = "transparent"; subtitleContainer.style.marginBottom = "0"; + subtitleContainer.style.pointerEvents = "none"; // Horizontal positioning based on \an alignment. // All alignments position the container at the left margin with full available @@ -916,47 +829,89 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( subtitleContainer.style.left = `${leftInset + marginX}px`; subtitleContainer.style.right = ""; subtitleContainer.style.transform = ""; + subtitleContainer.style.textAlign = ""; if (hAlign === 0) { + subtitleContainer.style.textAlign = "left"; subtitleRoot.style.textAlign = "left"; } else if (hAlign === 2) { + subtitleContainer.style.textAlign = "right"; subtitleRoot.style.textAlign = "right"; } else { + subtitleContainer.style.textAlign = "center"; subtitleRoot.style.textAlign = "center"; } + subtitleRoot.style.display = "inline-block"; + subtitleRoot.style.maxWidth = "100%"; + subtitleRoot.style.pointerEvents = "auto"; // Vertical positioning based on \an alignment + const lineCount = Math.max(1, currentInvisibleSubtitleLineCount); + const multiline = lineCount > 1; + const baselineCompensationFactor = + lineCount >= 3 ? 0.46 : multiline ? 0.58 : 0.7; + const baselineCompensationPx = Math.max( + 0, + effectiveFontSize * baselineCompensationFactor, + ); if (vAlign === 2) { - subtitleContainer.style.top = `${topInset + marginY}px`; + subtitleContainer.style.top = `${Math.max(0, topInset + marginY - baselineCompensationPx)}px`; subtitleContainer.style.bottom = ""; } else if (vAlign === 1) { subtitleContainer.style.top = "50%"; subtitleContainer.style.bottom = ""; subtitleContainer.style.transform = "translateY(-50%)"; } else { - const subPosOffset = + const subPosMargin = ((100 - mpvSubtitleRenderMetrics.subPos) / 100) * renderAreaHeight; - const bottomPx = Math.max(0, bottomInset + marginY + subPosOffset); + const effectiveMargin = Math.max(marginY, subPosMargin); + const bottomPx = Math.max( + 0, + bottomInset + effectiveMargin + baselineCompensationPx, + ); subtitleContainer.style.top = ""; subtitleContainer.style.bottom = `${bottomPx}px`; } + subtitleRoot.style.setProperty( + "line-height", + isMacOSPlatform + ? lineCount >= 3 + ? "1.18" + : multiline + ? "1.08" + : "0.86" + : "normal", + isMacOSPlatform ? "important" : "", + ); + + const rawFont = mpvSubtitleRenderMetrics.subFont; + const strippedFont = rawFont + .replace( + /\s+(Regular|Bold|Italic|Light|Medium|Semi\s*Bold|Extra\s*Bold|Extra\s*Light|Thin|Black|Heavy|Demi\s*Bold|Book|Condensed)\s*$/i, + "", + ) + .trim(); subtitleRoot.style.fontFamily = - assOverrides.fontFamily || mpvSubtitleRenderMetrics.subFont; - const effectiveSpacing = - typeof assOverrides.letterSpacing === "number" - ? assOverrides.letterSpacing - : mpvSubtitleRenderMetrics.subSpacing; - subtitleRoot.style.letterSpacing = + strippedFont !== rawFont + ? `"${rawFont}", "${strippedFont}", sans-serif` + : `"${rawFont}", sans-serif`; + const effectiveSpacing = mpvSubtitleRenderMetrics.subSpacing; + subtitleRoot.style.setProperty( + "letter-spacing", Math.abs(effectiveSpacing) > 0.0001 - ? `${effectiveSpacing * pxPerScaledPixel}px` - : "normal"; - const effectiveBold = assOverrides.bold ?? mpvSubtitleRenderMetrics.subBold; - const effectiveItalic = - assOverrides.italic ?? mpvSubtitleRenderMetrics.subItalic; + ? `${effectiveSpacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px` + : isMacOSPlatform + ? "-0.02em" + : "0px", + isMacOSPlatform ? "important" : "", + ); + subtitleRoot.style.fontKerning = isMacOSPlatform ? "auto" : "none"; + const effectiveBold = mpvSubtitleRenderMetrics.subBold; + const effectiveItalic = mpvSubtitleRenderMetrics.subItalic; subtitleRoot.style.fontWeight = effectiveBold ? "700" : "400"; subtitleRoot.style.fontStyle = effectiveItalic ? "italic" : "normal"; - const scaleX = assOverrides.scaleX ?? 1; - const scaleY = assOverrides.scaleY ?? 1; + const scaleX = 1; + const scaleY = 1; if (Math.abs(scaleX - 1) > 0.0001 || Math.abs(scaleY - 1) > 0.0001) { subtitleRoot.style.transform = `scale(${scaleX}, ${scaleY})`; subtitleRoot.style.transformOrigin = "50% 100%"; @@ -968,7 +923,7 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( // CSS line-height: normal adds "half-leading" — extra space equally distributed // above and below text within each line box. This means the text's visual bottom // is above the element's bottom edge by halfLeading pixels. We must compensate - // by shifting the element down by that amount so glyph positions match mpv/libass. + // by shifting the element down by that amount so glyph positions better match mpv. const computedLineHeight = parseFloat( getComputedStyle(subtitleRoot).lineHeight, ); @@ -989,12 +944,7 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( } } } - console.log( - "[invisible-overlay] Applied mpv subtitle render metrics from", - source, - mpvSubtitleRenderMetrics, - assOverrides, - ); + console.log("[invisible-overlay] Applied mpv subtitle render metrics from", source); } function setJimakuStatus(message: string, isError = false): void { @@ -1919,6 +1869,129 @@ function setupDragging(): void { }); } +function getCaretTextPointRange(clientX: number, clientY: number): Range | null { + const documentWithCaretApi = document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretPositionFromPoint?: ( + x: number, + y: number, + ) => { offsetNode: Node; offset: number } | null; + }; + + if (typeof documentWithCaretApi.caretRangeFromPoint === "function") { + return documentWithCaretApi.caretRangeFromPoint(clientX, clientY); + } + + if (typeof documentWithCaretApi.caretPositionFromPoint === "function") { + const caretPosition = documentWithCaretApi.caretPositionFromPoint( + clientX, + clientY, + ); + if (!caretPosition) return null; + const range = document.createRange(); + range.setStart(caretPosition.offsetNode, caretPosition.offset); + range.collapse(true); + return range; + } + + return null; +} + +function getWordBoundsAtOffset( + text: string, + offset: number, +): { start: number; end: number } | null { + if (!text || text.length === 0) return null; + + const clampedOffset = Math.max(0, Math.min(offset, text.length)); + const probeIndex = + clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset; + + if (wordSegmenter) { + for (const part of wordSegmenter.segment(text)) { + const start = part.index; + const end = start + part.segment.length; + if (probeIndex >= start && probeIndex < end) { + if (part.isWordLike === false) return null; + return { start, end }; + } + } + } + + const isBoundary = (char: string): boolean => + /[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test( + char, + ); + + const probeChar = text[probeIndex]; + if (!probeChar || isBoundary(probeChar)) return null; + + let start = probeIndex; + while (start > 0 && !isBoundary(text[start - 1])) { + start -= 1; + } + + let end = probeIndex + 1; + while (end < text.length && !isBoundary(text[end])) { + end += 1; + } + + if (end <= start) return null; + return { start, end }; +} + +function updateHoverWordSelection(event: MouseEvent): void { + if (!isInvisibleLayer || !isMacOSPlatform) return; + if (event.buttons !== 0) return; + if (!(event.target instanceof Node)) return; + if (!subtitleRoot.contains(event.target)) return; + + const caretRange = getCaretTextPointRange(event.clientX, event.clientY); + if (!caretRange) return; + if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) return; + if (!subtitleRoot.contains(caretRange.startContainer)) return; + + const textNode = caretRange.startContainer as Text; + const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset); + if (!wordBounds) return; + + const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice( + wordBounds.start, + wordBounds.end, + )}`; + if ( + selectionKey === lastHoverSelectionKey && + textNode === lastHoverSelectionNode + ) { + return; + } + + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + range.setStart(textNode, wordBounds.start); + range.setEnd(textNode, wordBounds.end); + + selection.removeAllRanges(); + selection.addRange(range); + lastHoverSelectionKey = selectionKey; + lastHoverSelectionNode = textNode; +} + +function setupInvisibleHoverSelection(): void { + if (!isInvisibleLayer || !isMacOSPlatform) return; + + subtitleRoot.addEventListener("mousemove", (event: MouseEvent) => { + updateHoverWordSelection(event); + }); + + subtitleRoot.addEventListener("mouseleave", () => { + lastHoverSelectionKey = ""; + lastHoverSelectionNode = null; + }); +} + function isInteractiveTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; if (target.closest(".modal")) return true; @@ -2267,16 +2340,6 @@ async function init(): Promise { renderSubtitle(data); }); - if (isInvisibleLayer) { - window.electronAPI.onSubtitleAss((assText: string) => { - currentSubtitleAss = assText || ""; - applyInvisibleSubtitleLayoutFromMpvMetrics( - mpvSubtitleRenderMetrics, - "subtitle-ass", - ); - }); - } - if (!isInvisibleLayer) { window.electronAPI.onSubtitlePosition( (position: SubtitlePosition | null) => { @@ -2296,9 +2359,6 @@ async function init(): Promise { }); } - if (isInvisibleLayer) { - currentSubtitleAss = await window.electronAPI.getCurrentSubtitleAss(); - } const initialSubtitle = await window.electronAPI.getCurrentSubtitle(); renderSubtitle(initialSubtitle); @@ -2316,8 +2376,10 @@ async function init(): Promise { const initialSecondary = await window.electronAPI.getCurrentSecondarySub(); renderSecondarySub(initialSecondary); - subtitleContainer.addEventListener("mouseenter", handleMouseEnter); - subtitleContainer.addEventListener("mouseleave", handleMouseLeave); + const hoverTarget = isInvisibleLayer ? subtitleRoot : subtitleContainer; + hoverTarget.addEventListener("mouseenter", handleMouseEnter); + hoverTarget.addEventListener("mouseleave", handleMouseLeave); + setupInvisibleHoverSelection(); secondarySubContainer.addEventListener("mouseenter", handleMouseEnter); secondarySubContainer.addEventListener("mouseleave", handleMouseLeave); diff --git a/src/renderer/style.css b/src/renderer/style.css index 3e1d13b..e03b344 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -34,6 +34,7 @@ body { } #overlay { + position: relative; width: 100%; height: 100%; display: flex; @@ -300,6 +301,8 @@ body.layer-invisible #subtitleContainer { border: 0 !important; padding: 0 !important; border-radius: 0 !important; + position: relative; + z-index: 3; } body.layer-invisible #subtitleRoot, @@ -342,10 +345,9 @@ body.layer-invisible.debug-invisible-visualization #subtitleRoot .word, body.layer-invisible.debug-invisible-visualization #subtitleRoot .c { color: #ed8796 !important; -webkit-text-fill-color: #ed8796 !important; - -webkit-text-stroke: 0.55px rgba(0, 0, 0, 0.95) !important; - text-shadow: - 0 0 8px rgba(237, 135, 150, 0.9), - 0 2px 6px rgba(0, 0, 0, 0.95) !important; + -webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important; + paint-order: stroke fill !important; + text-shadow: none !important; } #secondarySubContainer { From f868fdbbb381d2f1a335f34123edf187932f15f8 Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:13:47 -0800 Subject: [PATCH 05/74] refactor(core): consolidate services and remove runtime wrappers --- ...time-service.ts => anki-jimaku-service.ts} | 0 ...app-lifecycle-deps-runtime-service.test.ts | 79 ------ .../app-lifecycle-deps-runtime-service.ts | 57 ---- src/core/services/app-lifecycle-service.ts | 63 ++++- .../app-logging-runtime-service.test.ts | 28 -- .../services/app-logging-runtime-service.ts | 28 -- ...vice.test.ts => app-ready-service.test.ts} | 2 +- .../app-shutdown-runtime-service.test.ts | 32 --- .../services/app-shutdown-runtime-service.ts | 27 -- .../cli-command-deps-runtime-service.test.ts | 110 -------- .../cli-command-deps-runtime-service.ts | 132 ---------- src/core/services/cli-command-service.ts | 131 ++++++++++ .../config-generation-runtime-service.test.ts | 67 ----- .../config-generation-runtime-service.ts | 26 -- .../config-warning-runtime-service.test.ts | 34 --- .../config-warning-runtime-service.ts | 14 - ...=> field-grouping-overlay-service.test.ts} | 2 +- ...e.ts => field-grouping-overlay-service.ts} | 2 +- src/core/services/field-grouping-service.ts | 14 +- src/core/services/index.ts | 56 ++-- .../services/ipc-deps-runtime-service.test.ts | 108 -------- src/core/services/ipc-deps-runtime-service.ts | 100 ------- src/core/services/ipc-service.ts | 101 ++++++- ...u-runtime-service.ts => jimaku-service.ts} | 0 ...g-runtime-service.ts => mining-service.ts} | 0 ...ce.test.ts => mpv-control-service.test.ts} | 2 +- ...time-service.ts => mpv-control-service.ts} | 0 .../numeric-shortcut-runtime-service.test.ts | 48 ---- .../numeric-shortcut-runtime-service.ts | 37 --- ...service.ts => numeric-shortcut-service.ts} | 34 +++ .../numeric-shortcut-session-service.test.ts | 50 +++- ...test.ts => overlay-bridge-service.test.ts} | 2 +- ...e-service.ts => overlay-bridge-service.ts} | 29 +-- .../overlay-broadcast-runtime-service.test.ts | 58 ----- .../overlay-broadcast-runtime-service.ts | 45 ---- .../services/overlay-manager-service.test.ts | 58 ++++- src/core/services/overlay-manager-service.ts | 33 +++ .../overlay-modal-restore-service.test.ts | 30 --- .../services/overlay-modal-restore-service.ts | 18 -- src/core/services/overlay-send-service.ts | 26 -- .../overlay-shortcut-fallback-runner.ts | 114 -------- ...service.ts => overlay-shortcut-handler.ts} | 117 ++++++++- .../overlay-shortcut-lifecycle-service.ts | 52 ---- src/core/services/overlay-shortcut-service.ts | 50 ++++ .../overlay-visibility-runtime-service.ts | 46 ---- .../services/overlay-visibility-service.ts | 63 +++-- ...ts => runtime-options-ipc-service.test.ts} | 2 +- ...vice.ts => runtime-options-ipc-service.ts} | 0 ...me-options-manager-runtime-service.test.ts | 25 -- ...runtime-options-manager-runtime-service.ts | 17 -- .../shortcut-ui-deps-runtime-service.test.ts | 62 ----- .../shortcut-ui-deps-runtime-service.ts | 24 -- .../startup-bootstrap-runtime-service.ts | 53 ---- ...t.ts => startup-bootstrap-service.test.ts} | 2 +- .../startup-resource-runtime-service.test.ts | 36 --- .../startup-resource-runtime-service.ts | 26 -- ...-runtime-service.ts => startup-service.ts} | 58 ++++- ...e-service.ts => subsync-runner-service.ts} | 0 .../tokenizer-deps-runtime-service.test.ts | 48 ---- .../tokenizer-deps-runtime-service.ts | 45 ---- src/core/services/tokenizer-service.ts | 43 ++- src/main.ts | 246 +++++++++--------- 62 files changed, 954 insertions(+), 1858 deletions(-) rename src/core/services/{anki-jimaku-runtime-service.ts => anki-jimaku-service.ts} (100%) delete mode 100644 src/core/services/app-lifecycle-deps-runtime-service.test.ts delete mode 100644 src/core/services/app-lifecycle-deps-runtime-service.ts delete mode 100644 src/core/services/app-logging-runtime-service.test.ts delete mode 100644 src/core/services/app-logging-runtime-service.ts rename src/core/services/{app-ready-runtime-service.test.ts => app-ready-service.test.ts} (98%) delete mode 100644 src/core/services/app-shutdown-runtime-service.test.ts delete mode 100644 src/core/services/app-shutdown-runtime-service.ts delete mode 100644 src/core/services/cli-command-deps-runtime-service.test.ts delete mode 100644 src/core/services/cli-command-deps-runtime-service.ts delete mode 100644 src/core/services/config-generation-runtime-service.test.ts delete mode 100644 src/core/services/config-generation-runtime-service.ts delete mode 100644 src/core/services/config-warning-runtime-service.test.ts delete mode 100644 src/core/services/config-warning-runtime-service.ts rename src/core/services/{field-grouping-overlay-runtime-service.test.ts => field-grouping-overlay-service.test.ts} (98%) rename src/core/services/{field-grouping-overlay-runtime-service.ts => field-grouping-overlay-service.ts} (98%) delete mode 100644 src/core/services/ipc-deps-runtime-service.test.ts delete mode 100644 src/core/services/ipc-deps-runtime-service.ts rename src/core/services/{jimaku-runtime-service.ts => jimaku-service.ts} (100%) rename src/core/services/{mining-runtime-service.ts => mining-service.ts} (100%) rename src/core/services/{mpv-runtime-service.test.ts => mpv-control-service.test.ts} (98%) rename src/core/services/{mpv-runtime-service.ts => mpv-control-service.ts} (100%) delete mode 100644 src/core/services/numeric-shortcut-runtime-service.test.ts delete mode 100644 src/core/services/numeric-shortcut-runtime-service.ts rename src/core/services/{numeric-shortcut-session-service.ts => numeric-shortcut-service.ts} (70%) rename src/core/services/{overlay-bridge-runtime-service.test.ts => overlay-bridge-service.test.ts} (98%) rename src/core/services/{overlay-bridge-runtime-service.ts => overlay-bridge-service.ts} (73%) delete mode 100644 src/core/services/overlay-broadcast-runtime-service.test.ts delete mode 100644 src/core/services/overlay-broadcast-runtime-service.ts delete mode 100644 src/core/services/overlay-modal-restore-service.test.ts delete mode 100644 src/core/services/overlay-modal-restore-service.ts delete mode 100644 src/core/services/overlay-send-service.ts delete mode 100644 src/core/services/overlay-shortcut-fallback-runner.ts rename src/core/services/{overlay-shortcut-runtime-service.ts => overlay-shortcut-handler.ts} (53%) delete mode 100644 src/core/services/overlay-shortcut-lifecycle-service.ts delete mode 100644 src/core/services/overlay-visibility-runtime-service.ts rename src/core/services/{runtime-options-runtime-service.test.ts => runtime-options-ipc-service.test.ts} (97%) rename src/core/services/{runtime-options-runtime-service.ts => runtime-options-ipc-service.ts} (100%) delete mode 100644 src/core/services/runtime-options-manager-runtime-service.test.ts delete mode 100644 src/core/services/runtime-options-manager-runtime-service.ts delete mode 100644 src/core/services/shortcut-ui-deps-runtime-service.test.ts delete mode 100644 src/core/services/shortcut-ui-deps-runtime-service.ts delete mode 100644 src/core/services/startup-bootstrap-runtime-service.ts rename src/core/services/{startup-bootstrap-runtime-service.test.ts => startup-bootstrap-service.test.ts} (98%) delete mode 100644 src/core/services/startup-resource-runtime-service.test.ts delete mode 100644 src/core/services/startup-resource-runtime-service.ts rename src/core/services/{app-ready-runtime-service.ts => startup-service.ts} (58%) rename src/core/services/{subsync-runtime-service.ts => subsync-runner-service.ts} (100%) delete mode 100644 src/core/services/tokenizer-deps-runtime-service.test.ts delete mode 100644 src/core/services/tokenizer-deps-runtime-service.ts diff --git a/src/core/services/anki-jimaku-runtime-service.ts b/src/core/services/anki-jimaku-service.ts similarity index 100% rename from src/core/services/anki-jimaku-runtime-service.ts rename to src/core/services/anki-jimaku-service.ts diff --git a/src/core/services/app-lifecycle-deps-runtime-service.test.ts b/src/core/services/app-lifecycle-deps-runtime-service.test.ts deleted file mode 100644 index 7eb8075..0000000 --- a/src/core/services/app-lifecycle-deps-runtime-service.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createAppLifecycleDepsRuntimeService } from "./app-lifecycle-deps-runtime-service"; - -test("createAppLifecycleDepsRuntimeService maps app methods and platform", async () => { - const events: Record void> = {}; - let lockRequested = 0; - let quitCalled = 0; - const deps = createAppLifecycleDepsRuntimeService({ - app: { - requestSingleInstanceLock: () => { - lockRequested += 1; - return true; - }, - quit: () => { - quitCalled += 1; - }, - on: (event, listener) => { - events[event] = listener; - }, - whenReady: async () => {}, - }, - platform: "darwin", - shouldStartApp: () => true, - parseArgs: () => ({ - start: false, - stop: false, - toggle: false, - toggleVisibleOverlay: false, - toggleInvisibleOverlay: false, - settings: false, - show: false, - hide: false, - showVisibleOverlay: false, - hideVisibleOverlay: false, - showInvisibleOverlay: false, - hideInvisibleOverlay: false, - copySubtitle: false, - copySubtitleMultiple: false, - mineSentence: false, - mineSentenceMultiple: false, - updateLastCardFromClipboard: false, - toggleSecondarySub: false, - triggerFieldGrouping: false, - triggerSubsync: false, - markAudioCard: false, - openRuntimeOptions: false, - texthooker: false, - help: false, - autoStartOverlay: false, - generateConfig: false, - backupOverwrite: false, - verbose: false, - }), - handleCliCommand: () => {}, - printHelp: () => {}, - logNoRunningInstance: () => {}, - onReady: async () => {}, - onWillQuitCleanup: () => {}, - shouldRestoreWindowsOnActivate: () => false, - restoreWindowsOnActivate: () => {}, - }); - - assert.equal(deps.requestSingleInstanceLock(), true); - deps.quitApp(); - assert.equal(lockRequested, 1); - assert.equal(quitCalled, 1); - assert.equal(deps.isDarwinPlatform(), true); - - let callbackRan = false; - deps.whenReady(async () => { - callbackRan = true; - }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(callbackRan, true); - - deps.onActivate(() => {}); - assert.equal(typeof events["activate"], "function"); -}); diff --git a/src/core/services/app-lifecycle-deps-runtime-service.ts b/src/core/services/app-lifecycle-deps-runtime-service.ts deleted file mode 100644 index 127f249..0000000 --- a/src/core/services/app-lifecycle-deps-runtime-service.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { CliArgs, CliCommandSource } from "../../cli/args"; -import { AppLifecycleServiceDeps } from "./app-lifecycle-service"; - -interface AppLike { - requestSingleInstanceLock: () => boolean; - quit: () => void; - on: (...args: any[]) => unknown; - whenReady: () => Promise; -} - -export interface AppLifecycleDepsRuntimeOptions { - app: AppLike; - platform: NodeJS.Platform; - shouldStartApp: (args: CliArgs) => boolean; - parseArgs: (argv: string[]) => CliArgs; - handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; - printHelp: () => void; - logNoRunningInstance: () => void; - onReady: () => Promise; - onWillQuitCleanup: () => void; - shouldRestoreWindowsOnActivate: () => boolean; - restoreWindowsOnActivate: () => void; -} - -export function createAppLifecycleDepsRuntimeService( - options: AppLifecycleDepsRuntimeOptions, -): AppLifecycleServiceDeps { - return { - shouldStartApp: options.shouldStartApp, - parseArgs: options.parseArgs, - requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(), - quitApp: () => options.app.quit(), - onSecondInstance: (handler) => { - options.app.on("second-instance", handler as (...args: unknown[]) => void); - }, - handleCliCommand: options.handleCliCommand, - printHelp: options.printHelp, - logNoRunningInstance: options.logNoRunningInstance, - whenReady: (handler) => { - options.app.whenReady().then(handler); - }, - onWindowAllClosed: (handler) => { - options.app.on("window-all-closed", handler as (...args: unknown[]) => void); - }, - onWillQuit: (handler) => { - options.app.on("will-quit", handler as (...args: unknown[]) => void); - }, - onActivate: (handler) => { - options.app.on("activate", handler as (...args: unknown[]) => void); - }, - isDarwinPlatform: () => options.platform === "darwin", - onReady: options.onReady, - onWillQuitCleanup: options.onWillQuitCleanup, - shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate, - restoreWindowsOnActivate: options.restoreWindowsOnActivate, - }; -} diff --git a/src/core/services/app-lifecycle-service.ts b/src/core/services/app-lifecycle-service.ts index 2b11a7b..b4a795a 100644 --- a/src/core/services/app-lifecycle-service.ts +++ b/src/core/services/app-lifecycle-service.ts @@ -20,6 +20,63 @@ export interface AppLifecycleServiceDeps { restoreWindowsOnActivate: () => void; } +interface AppLike { + requestSingleInstanceLock: () => boolean; + quit: () => void; + on: (...args: any[]) => unknown; + whenReady: () => Promise; +} + +export interface AppLifecycleDepsRuntimeOptions { + app: AppLike; + platform: NodeJS.Platform; + shouldStartApp: (args: CliArgs) => boolean; + parseArgs: (argv: string[]) => CliArgs; + handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; + printHelp: () => void; + logNoRunningInstance: () => void; + onReady: () => Promise; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; +} + +export function createAppLifecycleDepsRuntimeService( + options: AppLifecycleDepsRuntimeOptions, +): AppLifecycleServiceDeps { + return { + shouldStartApp: options.shouldStartApp, + parseArgs: options.parseArgs, + requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(), + quitApp: () => options.app.quit(), + onSecondInstance: (handler) => { + options.app.on("second-instance", handler as (...args: unknown[]) => void); + }, + handleCliCommand: options.handleCliCommand, + printHelp: options.printHelp, + logNoRunningInstance: options.logNoRunningInstance, + whenReady: (handler) => { + options.app.whenReady().then(handler).catch((error) => { + console.error("App ready handler failed:", error); + }); + }, + onWindowAllClosed: (handler) => { + options.app.on("window-all-closed", handler as (...args: unknown[]) => void); + }, + onWillQuit: (handler) => { + options.app.on("will-quit", handler as (...args: unknown[]) => void); + }, + onActivate: (handler) => { + options.app.on("activate", handler as (...args: unknown[]) => void); + }, + isDarwinPlatform: () => options.platform === "darwin", + onReady: options.onReady, + onWillQuitCleanup: options.onWillQuitCleanup, + shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate: options.restoreWindowsOnActivate, + }; +} + export function startAppLifecycleService( initialArgs: CliArgs, deps: AppLifecycleServiceDeps, @@ -31,7 +88,11 @@ export function startAppLifecycleService( } deps.onSecondInstance((_event, argv) => { - deps.handleCliCommand(deps.parseArgs(argv), "second-instance"); + try { + deps.handleCliCommand(deps.parseArgs(argv), "second-instance"); + } catch (error) { + console.error("Failed to handle second-instance CLI command:", error); + } }); if (initialArgs.help && !deps.shouldStartApp(initialArgs)) { diff --git a/src/core/services/app-logging-runtime-service.test.ts b/src/core/services/app-logging-runtime-service.test.ts deleted file mode 100644 index 535345d..0000000 --- a/src/core/services/app-logging-runtime-service.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createAppLoggingRuntimeService } from "./app-logging-runtime-service"; - -test("createAppLoggingRuntimeService routes logs and formats config warnings", () => { - const lines: string[] = []; - const logger = { - log: (line: string) => lines.push(`log:${line}`), - warn: (line: string) => lines.push(`warn:${line}`), - error: (line: string) => lines.push(`error:${line}`), - }; - - const runtime = createAppLoggingRuntimeService(logger); - runtime.logInfo("hello"); - runtime.logWarning("careful"); - runtime.logNoRunningInstance(); - runtime.logConfigWarning({ - path: "x.y", - value: "bad", - fallback: "good", - message: "invalid", - }); - - assert.equal(lines[0], "log:hello"); - assert.equal(lines[1], "warn:careful"); - assert.equal(lines[2], "error:No running instance. Use --start to launch the app."); - assert.match(lines[3], /^warn:\[config\] x\.y: invalid /); -}); diff --git a/src/core/services/app-logging-runtime-service.ts b/src/core/services/app-logging-runtime-service.ts deleted file mode 100644 index d9522a9..0000000 --- a/src/core/services/app-logging-runtime-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ConfigValidationWarning } from "../../types"; -import { logConfigWarningRuntimeService } from "./config-warning-runtime-service"; - -export interface AppLoggingRuntime { - logInfo: (message: string) => void; - logWarning: (message: string) => void; - logNoRunningInstance: () => void; - logConfigWarning: (warning: ConfigValidationWarning) => void; -} - -export function createAppLoggingRuntimeService( - logger: Pick = console, -): AppLoggingRuntime { - return { - logInfo: (message) => { - logger.log(message); - }, - logWarning: (message) => { - logger.warn(message); - }, - logNoRunningInstance: () => { - logger.error("No running instance. Use --start to launch the app."); - }, - logConfigWarning: (warning) => { - logConfigWarningRuntimeService(warning, (line) => logger.warn(line)); - }, - }; -} diff --git a/src/core/services/app-ready-runtime-service.test.ts b/src/core/services/app-ready-service.test.ts similarity index 98% rename from src/core/services/app-ready-runtime-service.test.ts rename to src/core/services/app-ready-service.test.ts index 7c1190d..002d0a9 100644 --- a/src/core/services/app-ready-runtime-service.test.ts +++ b/src/core/services/app-ready-service.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { AppReadyRuntimeDeps, runAppReadyRuntimeService } from "./app-ready-runtime-service"; +import { AppReadyRuntimeDeps, runAppReadyRuntimeService } from "./startup-service"; function makeDeps(overrides: Partial = {}) { const calls: string[] = []; diff --git a/src/core/services/app-shutdown-runtime-service.test.ts b/src/core/services/app-shutdown-runtime-service.test.ts deleted file mode 100644 index aa45bea..0000000 --- a/src/core/services/app-shutdown-runtime-service.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { runAppShutdownRuntimeService } from "./app-shutdown-runtime-service"; - -test("runAppShutdownRuntimeService runs teardown steps in order", () => { - const calls: string[] = []; - runAppShutdownRuntimeService({ - unregisterAllGlobalShortcuts: () => calls.push("unregisterAllGlobalShortcuts"), - stopSubtitleWebsocket: () => calls.push("stopSubtitleWebsocket"), - stopTexthookerService: () => calls.push("stopTexthookerService"), - destroyYomitanParserWindow: () => calls.push("destroyYomitanParserWindow"), - clearYomitanParserPromises: () => calls.push("clearYomitanParserPromises"), - stopWindowTracker: () => calls.push("stopWindowTracker"), - destroyMpvSocket: () => calls.push("destroyMpvSocket"), - clearReconnectTimer: () => calls.push("clearReconnectTimer"), - destroySubtitleTimingTracker: () => calls.push("destroySubtitleTimingTracker"), - destroyAnkiIntegration: () => calls.push("destroyAnkiIntegration"), - }); - - assert.deepEqual(calls, [ - "unregisterAllGlobalShortcuts", - "stopSubtitleWebsocket", - "stopTexthookerService", - "destroyYomitanParserWindow", - "clearYomitanParserPromises", - "stopWindowTracker", - "destroyMpvSocket", - "clearReconnectTimer", - "destroySubtitleTimingTracker", - "destroyAnkiIntegration", - ]); -}); diff --git a/src/core/services/app-shutdown-runtime-service.ts b/src/core/services/app-shutdown-runtime-service.ts deleted file mode 100644 index 7680387..0000000 --- a/src/core/services/app-shutdown-runtime-service.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface AppShutdownRuntimeDeps { - unregisterAllGlobalShortcuts: () => void; - stopSubtitleWebsocket: () => void; - stopTexthookerService: () => void; - destroyYomitanParserWindow: () => void; - clearYomitanParserPromises: () => void; - stopWindowTracker: () => void; - destroyMpvSocket: () => void; - clearReconnectTimer: () => void; - destroySubtitleTimingTracker: () => void; - destroyAnkiIntegration: () => void; -} - -export function runAppShutdownRuntimeService( - deps: AppShutdownRuntimeDeps, -): void { - deps.unregisterAllGlobalShortcuts(); - deps.stopSubtitleWebsocket(); - deps.stopTexthookerService(); - deps.destroyYomitanParserWindow(); - deps.clearYomitanParserPromises(); - deps.stopWindowTracker(); - deps.destroyMpvSocket(); - deps.clearReconnectTimer(); - deps.destroySubtitleTimingTracker(); - deps.destroyAnkiIntegration(); -} diff --git a/src/core/services/cli-command-deps-runtime-service.test.ts b/src/core/services/cli-command-deps-runtime-service.test.ts deleted file mode 100644 index 81370fc..0000000 --- a/src/core/services/cli-command-deps-runtime-service.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createCliCommandDepsRuntimeService } from "./cli-command-deps-runtime-service"; - -test("createCliCommandDepsRuntimeService wires runtime helpers", () => { - let socketPath = "/tmp/mpv"; - let setClientSocketPath: string | null = null; - let connectCalls = 0; - let texthookerPort = 7000; - let texthookerRunning = false; - let texthookerStartPort: number | null = null; - let overlayVisible = false; - let overlayInvisible = false; - let openYomitanAfterDelay: number | null = null; - - const deps = createCliCommandDepsRuntimeService({ - mpv: { - getSocketPath: () => socketPath, - setSocketPath: (next) => { - socketPath = next; - }, - getClient: () => ({ - setSocketPath: (next) => { - setClientSocketPath = next; - }, - connect: () => { - connectCalls += 1; - }, - }), - showOsd: () => {}, - }, - texthooker: { - service: { - isRunning: () => texthookerRunning, - start: (port) => { - texthookerRunning = true; - texthookerStartPort = port; - }, - }, - getPort: () => texthookerPort, - setPort: (port) => { - texthookerPort = port; - }, - shouldOpenBrowser: () => true, - openInBrowser: () => {}, - }, - overlay: { - isInitialized: () => false, - initialize: () => {}, - toggleVisible: () => { - overlayVisible = !overlayVisible; - }, - toggleInvisible: () => { - overlayInvisible = !overlayInvisible; - }, - setVisible: (visible) => { - overlayVisible = visible; - }, - setInvisible: (visible) => { - overlayInvisible = visible; - }, - }, - mining: { - copyCurrentSubtitle: () => {}, - startPendingMultiCopy: () => {}, - mineSentenceCard: async () => {}, - startPendingMineSentenceMultiple: () => {}, - updateLastCardFromClipboard: async () => {}, - triggerFieldGrouping: async () => {}, - triggerSubsyncFromConfig: async () => {}, - markLastCardAsAudioCard: async () => {}, - }, - ui: { - openYomitanSettings: () => {}, - cycleSecondarySubMode: () => {}, - openRuntimeOptionsPalette: () => {}, - printHelp: () => {}, - }, - app: { - stop: () => {}, - hasMainWindow: () => true, - }, - getMultiCopyTimeoutMs: () => 2500, - schedule: (_fn, delayMs) => { - openYomitanAfterDelay = delayMs; - return null; - }, - log: () => {}, - warn: () => {}, - error: () => {}, - }); - - deps.setMpvSocketPath("/tmp/new"); - deps.setMpvClientSocketPath("/tmp/new"); - deps.connectMpvClient(); - deps.ensureTexthookerRunning(9000); - deps.openYomitanSettingsDelayed(1000); - deps.toggleVisibleOverlay(); - deps.toggleInvisibleOverlay(); - - assert.equal(deps.getMpvSocketPath(), "/tmp/new"); - assert.equal(setClientSocketPath, "/tmp/new"); - assert.equal(connectCalls, 1); - assert.equal(texthookerStartPort, 9000); - assert.equal(texthookerPort, 7000); - assert.equal(openYomitanAfterDelay, 1000); - assert.equal(overlayVisible, true); - assert.equal(overlayInvisible, true); - assert.equal(deps.getMultiCopyTimeoutMs(), 2500); -}); diff --git a/src/core/services/cli-command-deps-runtime-service.ts b/src/core/services/cli-command-deps-runtime-service.ts deleted file mode 100644 index 7bb06d4..0000000 --- a/src/core/services/cli-command-deps-runtime-service.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { CliCommandServiceDeps } from "./cli-command-service"; - -interface MpvClientLike { - setSocketPath: (socketPath: string) => void; - connect: () => void; -} - -interface TexthookerServiceLike { - isRunning: () => boolean; - start: (port: number) => void; -} - -interface MpvCliRuntime { - getSocketPath: () => string; - setSocketPath: (socketPath: string) => void; - getClient: () => MpvClientLike | null; - showOsd: (text: string) => void; -} - -interface TexthookerCliRuntime { - service: TexthookerServiceLike; - getPort: () => number; - setPort: (port: number) => void; - shouldOpenBrowser: () => boolean; - openInBrowser: (url: string) => void; -} - -interface OverlayCliRuntime { - isInitialized: () => boolean; - initialize: () => void; - toggleVisible: () => void; - toggleInvisible: () => void; - setVisible: (visible: boolean) => void; - setInvisible: (visible: boolean) => void; -} - -interface MiningCliRuntime { - copyCurrentSubtitle: () => void; - startPendingMultiCopy: (timeoutMs: number) => void; - mineSentenceCard: () => Promise; - startPendingMineSentenceMultiple: (timeoutMs: number) => void; - updateLastCardFromClipboard: () => Promise; - triggerFieldGrouping: () => Promise; - triggerSubsyncFromConfig: () => Promise; - markLastCardAsAudioCard: () => Promise; -} - -interface UiCliRuntime { - openYomitanSettings: () => void; - cycleSecondarySubMode: () => void; - openRuntimeOptionsPalette: () => void; - printHelp: () => void; -} - -interface AppCliRuntime { - stop: () => void; - hasMainWindow: () => boolean; -} - -export interface CliCommandDepsRuntimeOptions { - mpv: MpvCliRuntime; - texthooker: TexthookerCliRuntime; - overlay: OverlayCliRuntime; - mining: MiningCliRuntime; - ui: UiCliRuntime; - app: AppCliRuntime; - getMultiCopyTimeoutMs: () => number; - schedule: (fn: () => void, delayMs: number) => unknown; - log: (message: string) => void; - warn: (message: string) => void; - error: (message: string, err: unknown) => void; -} - -export function createCliCommandDepsRuntimeService( - options: CliCommandDepsRuntimeOptions, -): CliCommandServiceDeps { - return { - getMpvSocketPath: options.mpv.getSocketPath, - setMpvSocketPath: options.mpv.setSocketPath, - setMpvClientSocketPath: (socketPath) => { - const client = options.mpv.getClient(); - if (!client) return; - client.setSocketPath(socketPath); - }, - hasMpvClient: () => Boolean(options.mpv.getClient()), - connectMpvClient: () => { - const client = options.mpv.getClient(); - if (!client) return; - client.connect(); - }, - isTexthookerRunning: () => options.texthooker.service.isRunning(), - setTexthookerPort: options.texthooker.setPort, - getTexthookerPort: options.texthooker.getPort, - shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser, - ensureTexthookerRunning: (port) => { - if (!options.texthooker.service.isRunning()) { - options.texthooker.service.start(port); - } - }, - openTexthookerInBrowser: options.texthooker.openInBrowser, - stopApp: options.app.stop, - isOverlayRuntimeInitialized: options.overlay.isInitialized, - initializeOverlayRuntime: options.overlay.initialize, - toggleVisibleOverlay: options.overlay.toggleVisible, - toggleInvisibleOverlay: options.overlay.toggleInvisible, - openYomitanSettingsDelayed: (delayMs) => { - options.schedule(() => { - options.ui.openYomitanSettings(); - }, delayMs); - }, - setVisibleOverlayVisible: options.overlay.setVisible, - setInvisibleOverlayVisible: options.overlay.setInvisible, - copyCurrentSubtitle: options.mining.copyCurrentSubtitle, - startPendingMultiCopy: options.mining.startPendingMultiCopy, - mineSentenceCard: options.mining.mineSentenceCard, - startPendingMineSentenceMultiple: - options.mining.startPendingMineSentenceMultiple, - updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard, - cycleSecondarySubMode: options.ui.cycleSecondarySubMode, - triggerFieldGrouping: options.mining.triggerFieldGrouping, - triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, - markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, - openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, - printHelp: options.ui.printHelp, - hasMainWindow: options.app.hasMainWindow, - getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, - showMpvOsd: options.mpv.showOsd, - log: options.log, - warn: options.warn, - error: options.error, - }; -} diff --git a/src/core/services/cli-command-service.ts b/src/core/services/cli-command-service.ts index 5e592e3..ac0fa26 100644 --- a/src/core/services/cli-command-service.ts +++ b/src/core/services/cli-command-service.ts @@ -43,6 +43,137 @@ export interface CliCommandServiceDeps { error: (message: string, err: unknown) => void; } +interface MpvClientLike { + setSocketPath: (socketPath: string) => void; + connect: () => void; +} + +interface TexthookerServiceLike { + isRunning: () => boolean; + start: (port: number) => void; +} + +interface MpvCliRuntime { + getSocketPath: () => string; + setSocketPath: (socketPath: string) => void; + getClient: () => MpvClientLike | null; + showOsd: (text: string) => void; +} + +interface TexthookerCliRuntime { + service: TexthookerServiceLike; + getPort: () => number; + setPort: (port: number) => void; + shouldOpenBrowser: () => boolean; + openInBrowser: (url: string) => void; +} + +interface OverlayCliRuntime { + isInitialized: () => boolean; + initialize: () => void; + toggleVisible: () => void; + toggleInvisible: () => void; + setVisible: (visible: boolean) => void; + setInvisible: (visible: boolean) => void; +} + +interface MiningCliRuntime { + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; +} + +interface UiCliRuntime { + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; +} + +interface AppCliRuntime { + stop: () => void; + hasMainWindow: () => boolean; +} + +export interface CliCommandDepsRuntimeOptions { + mpv: MpvCliRuntime; + texthooker: TexthookerCliRuntime; + overlay: OverlayCliRuntime; + mining: MiningCliRuntime; + ui: UiCliRuntime; + app: AppCliRuntime; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => unknown; + log: (message: string) => void; + warn: (message: string) => void; + error: (message: string, err: unknown) => void; +} + +export function createCliCommandDepsRuntimeService( + options: CliCommandDepsRuntimeOptions, +): CliCommandServiceDeps { + return { + getMpvSocketPath: options.mpv.getSocketPath, + setMpvSocketPath: options.mpv.setSocketPath, + setMpvClientSocketPath: (socketPath) => { + const client = options.mpv.getClient(); + if (!client) return; + client.setSocketPath(socketPath); + }, + hasMpvClient: () => Boolean(options.mpv.getClient()), + connectMpvClient: () => { + const client = options.mpv.getClient(); + if (!client) return; + client.connect(); + }, + isTexthookerRunning: () => options.texthooker.service.isRunning(), + setTexthookerPort: options.texthooker.setPort, + getTexthookerPort: options.texthooker.getPort, + shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser, + ensureTexthookerRunning: (port) => { + if (!options.texthooker.service.isRunning()) { + options.texthooker.service.start(port); + } + }, + openTexthookerInBrowser: options.texthooker.openInBrowser, + stopApp: options.app.stop, + isOverlayRuntimeInitialized: options.overlay.isInitialized, + initializeOverlayRuntime: options.overlay.initialize, + toggleVisibleOverlay: options.overlay.toggleVisible, + toggleInvisibleOverlay: options.overlay.toggleInvisible, + openYomitanSettingsDelayed: (delayMs) => { + options.schedule(() => { + options.ui.openYomitanSettings(); + }, delayMs); + }, + setVisibleOverlayVisible: options.overlay.setVisible, + setInvisibleOverlayVisible: options.overlay.setInvisible, + copyCurrentSubtitle: options.mining.copyCurrentSubtitle, + startPendingMultiCopy: options.mining.startPendingMultiCopy, + mineSentenceCard: options.mining.mineSentenceCard, + startPendingMineSentenceMultiple: + options.mining.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard, + cycleSecondarySubMode: options.ui.cycleSecondarySubMode, + triggerFieldGrouping: options.mining.triggerFieldGrouping, + triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, + markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, + openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, + printHelp: options.ui.printHelp, + hasMainWindow: options.app.hasMainWindow, + getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, + showMpvOsd: options.mpv.showOsd, + log: options.log, + warn: options.warn, + error: options.error, + }; +} + function runAsyncWithOsd( task: () => Promise, deps: CliCommandServiceDeps, diff --git a/src/core/services/config-generation-runtime-service.test.ts b/src/core/services/config-generation-runtime-service.test.ts deleted file mode 100644 index 2551292..0000000 --- a/src/core/services/config-generation-runtime-service.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { runGenerateConfigFlowRuntimeService } from "./config-generation-runtime-service"; -import { CliArgs } from "../../cli/args"; - -function makeArgs(overrides: Partial = {}): CliArgs { - return { - start: false, - stop: false, - toggle: false, - toggleVisibleOverlay: false, - toggleInvisibleOverlay: false, - settings: false, - show: false, - hide: false, - showVisibleOverlay: false, - hideVisibleOverlay: false, - showInvisibleOverlay: false, - hideInvisibleOverlay: false, - copySubtitle: false, - copySubtitleMultiple: false, - mineSentence: false, - mineSentenceMultiple: false, - updateLastCardFromClipboard: false, - toggleSecondarySub: false, - triggerFieldGrouping: false, - triggerSubsync: false, - markAudioCard: false, - openRuntimeOptions: false, - texthooker: false, - help: false, - autoStartOverlay: false, - generateConfig: false, - backupOverwrite: false, - verbose: false, - ...overrides, - }; -} - -test("runGenerateConfigFlowRuntimeService starts flow when generateConfig is set and app should not start", async () => { - const calls: string[] = []; - const handled = runGenerateConfigFlowRuntimeService( - makeArgs({ generateConfig: true }), - { - shouldStartApp: () => false, - generateConfig: async () => 7, - onSuccess: (code) => calls.push(`success:${code}`), - onError: () => calls.push("error"), - }, - ); - assert.equal(handled, true); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(calls, ["success:7"]); -}); - -test("runGenerateConfigFlowRuntimeService returns false when flow should not run", () => { - const handled = runGenerateConfigFlowRuntimeService( - makeArgs({ generateConfig: true, start: true }), - { - shouldStartApp: () => true, - generateConfig: async () => 0, - onSuccess: () => {}, - onError: () => {}, - }, - ); - assert.equal(handled, false); -}); diff --git a/src/core/services/config-generation-runtime-service.ts b/src/core/services/config-generation-runtime-service.ts deleted file mode 100644 index 14b34c6..0000000 --- a/src/core/services/config-generation-runtime-service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CliArgs } from "../../cli/args"; - -export interface ConfigGenerationRuntimeDeps { - shouldStartApp: (args: CliArgs) => boolean; - generateConfig: (args: CliArgs) => Promise; - onSuccess: (exitCode: number) => void; - onError: (error: Error) => void; -} - -export function runGenerateConfigFlowRuntimeService( - args: CliArgs, - deps: ConfigGenerationRuntimeDeps, -): boolean { - if (!args.generateConfig || deps.shouldStartApp(args)) { - return false; - } - - deps.generateConfig(args) - .then((exitCode) => { - deps.onSuccess(exitCode); - }) - .catch((error: Error) => { - deps.onError(error); - }); - return true; -} diff --git a/src/core/services/config-warning-runtime-service.test.ts b/src/core/services/config-warning-runtime-service.test.ts deleted file mode 100644 index 8181886..0000000 --- a/src/core/services/config-warning-runtime-service.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - formatConfigWarningRuntimeService, - logConfigWarningRuntimeService, -} from "./config-warning-runtime-service"; - -test("formatConfigWarningRuntimeService formats warning line", () => { - const message = formatConfigWarningRuntimeService({ - path: "ankiConnect.enabled", - value: "oops", - fallback: true, - message: "invalid type", - }); - assert.equal( - message, - '[config] ankiConnect.enabled: invalid type value="oops" fallback=true', - ); -}); - -test("logConfigWarningRuntimeService delegates to logger", () => { - const logs: string[] = []; - logConfigWarningRuntimeService( - { - path: "x.y", - value: 1, - fallback: 2, - message: "bad", - }, - (line) => logs.push(line), - ); - assert.equal(logs.length, 1); - assert.match(logs[0], /^\[config\] x\.y: bad /); -}); diff --git a/src/core/services/config-warning-runtime-service.ts b/src/core/services/config-warning-runtime-service.ts deleted file mode 100644 index 5cc44bd..0000000 --- a/src/core/services/config-warning-runtime-service.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ConfigValidationWarning } from "../../types"; - -export function formatConfigWarningRuntimeService( - warning: ConfigValidationWarning, -): string { - return `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`; -} - -export function logConfigWarningRuntimeService( - warning: ConfigValidationWarning, - log: (message: string) => void, -): void { - log(formatConfigWarningRuntimeService(warning)); -} diff --git a/src/core/services/field-grouping-overlay-runtime-service.test.ts b/src/core/services/field-grouping-overlay-service.test.ts similarity index 98% rename from src/core/services/field-grouping-overlay-runtime-service.test.ts rename to src/core/services/field-grouping-overlay-service.test.ts index a6ff621..9689332 100644 --- a/src/core/services/field-grouping-overlay-runtime-service.test.ts +++ b/src/core/services/field-grouping-overlay-service.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { KikuFieldGroupingChoice } from "../../types"; -import { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-runtime-service"; +import { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service"; test("createFieldGroupingOverlayRuntimeService sends overlay messages and sets restore flag", () => { const sent: unknown[][] = []; diff --git a/src/core/services/field-grouping-overlay-runtime-service.ts b/src/core/services/field-grouping-overlay-service.ts similarity index 98% rename from src/core/services/field-grouping-overlay-runtime-service.ts rename to src/core/services/field-grouping-overlay-service.ts index 21f4d6b..3c00fe8 100644 --- a/src/core/services/field-grouping-overlay-runtime-service.ts +++ b/src/core/services/field-grouping-overlay-service.ts @@ -5,7 +5,7 @@ import { import { createFieldGroupingCallbackRuntimeService, sendToVisibleOverlayRuntimeService, -} from "./overlay-bridge-runtime-service"; +} from "./overlay-bridge-service"; interface WindowLike { isDestroyed: () => boolean; diff --git a/src/core/services/field-grouping-service.ts b/src/core/services/field-grouping-service.ts index 431074e..6c0fbe7 100644 --- a/src/core/services/field-grouping-service.ts +++ b/src/core/services/field-grouping-service.ts @@ -16,6 +16,16 @@ export function createFieldGroupingCallbackService(options: { data: KikuFieldGroupingRequestData, ): Promise => { return new Promise((resolve) => { + if (options.getResolver()) { + resolve({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + return; + } + const previousVisibleOverlay = options.getVisibleOverlayVisible(); const previousInvisibleOverlay = options.getInvisibleOverlayVisible(); let settled = false; @@ -23,7 +33,9 @@ export function createFieldGroupingCallbackService(options: { const finish = (choice: KikuFieldGroupingChoice): void => { if (settled) return; settled = true; - options.setResolver(null); + if (options.getResolver() === finish) { + options.setResolver(null); + } resolve(choice); if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) { diff --git a/src/core/services/index.ts b/src/core/services/index.ts index c0be5b7..abb8e17 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -1,17 +1,17 @@ export { TexthookerService } from "./texthooker-service"; export { hasMpvWebsocketPlugin, SubtitleWebSocketService } from "./subtitle-ws-service"; export { registerGlobalShortcutsService } from "./shortcut-service"; -export { registerIpcHandlersService } from "./ipc-service"; +export { createIpcDepsRuntimeService, registerIpcHandlersService } from "./ipc-service"; export { isGlobalShortcutRegisteredSafe, shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service"; -export { registerOverlayShortcutsService } from "./overlay-shortcut-service"; -export { createOverlayShortcutRuntimeHandlers } from "./overlay-shortcut-runtime-service"; -export { handleCliCommandService } from "./cli-command-service"; -export { cycleSecondarySubModeService } from "./secondary-subtitle-service"; export { refreshOverlayShortcutsRuntimeService, + registerOverlayShortcutsService, syncOverlayShortcutsRuntimeService, unregisterOverlayShortcutsRuntimeService, -} from "./overlay-shortcut-lifecycle-service"; +} from "./overlay-shortcut-service"; +export { createOverlayShortcutRuntimeHandlers } from "./overlay-shortcut-handler"; +export { createCliCommandDepsRuntimeService, handleCliCommandService } from "./cli-command-service"; +export { cycleSecondarySubModeService } from "./secondary-subtitle-service"; export { copyCurrentSubtitleService, handleMineSentenceDigitService, @@ -20,15 +20,15 @@ export { mineSentenceCardService, triggerFieldGroupingService, updateLastCardFromClipboardService, -} from "./mining-runtime-service"; -export { startAppLifecycleService } from "./app-lifecycle-service"; +} from "./mining-service"; +export { createAppLifecycleDepsRuntimeService, startAppLifecycleService } from "./app-lifecycle-service"; export { playNextSubtitleRuntimeService, replayCurrentSubtitleRuntimeService, sendMpvCommandRuntimeService, setMpvSubVisibilityRuntimeService, showMpvOsdRuntimeService, -} from "./mpv-runtime-service"; +} from "./mpv-control-service"; export { getInitialInvisibleOverlayVisibilityService, isAutoUpdateEnabledRuntimeService, @@ -36,14 +36,14 @@ export { shouldBindVisibleOverlayToMpvSubVisibilityService, } from "./runtime-config-service"; export { openYomitanSettingsWindow } from "./yomitan-settings-service"; -export { tokenizeSubtitleService } from "./tokenizer-service"; +export { createTokenizerDepsRuntimeService, tokenizeSubtitleService } from "./tokenizer-service"; export { loadYomitanExtensionService } from "./yomitan-extension-loader-service"; export { getJimakuLanguagePreferenceService, getJimakuMaxEntryResultsService, jimakuFetchJsonService, resolveJimakuApiKeyService, -} from "./jimaku-runtime-service"; +} from "./jimaku-service"; export { loadSubtitlePositionService, saveSubtitlePositionService, @@ -60,33 +60,19 @@ export { setInvisibleOverlayVisibleService, setVisibleOverlayVisibleService, syncInvisibleOverlayMousePassthroughService, -} from "./overlay-visibility-runtime-service"; + updateInvisibleOverlayVisibilityService, + updateVisibleOverlayVisibilityService, +} from "./overlay-visibility-service"; export { MpvIpcClient, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-service"; export { applyMpvSubtitleRenderMetricsPatchService } from "./mpv-render-metrics-service"; export { handleMpvCommandFromIpcService } from "./ipc-command-service"; -export { handleOverlayModalClosedService } from "./overlay-modal-restore-service"; +export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service"; +export { createNumericShortcutRuntimeService } from "./numeric-shortcut-service"; +export { runStartupBootstrapRuntimeService } from "./startup-service"; +export { runSubsyncManualFromIpcRuntimeService, triggerSubsyncFromConfigRuntimeService } from "./subsync-runner-service"; +export { registerAnkiJimakuIpcRuntimeService } from "./anki-jimaku-service"; export { broadcastRuntimeOptionsChangedRuntimeService, - broadcastToOverlayWindowsRuntimeService, - getOverlayWindowsRuntimeService, + createOverlayManagerService, setOverlayDebugVisualizationEnabledRuntimeService, -} from "./overlay-broadcast-runtime-service"; -export { createAppLifecycleDepsRuntimeService } from "./app-lifecycle-deps-runtime-service"; -export { createCliCommandDepsRuntimeService } from "./cli-command-deps-runtime-service"; -export { createIpcDepsRuntimeService } from "./ipc-deps-runtime-service"; -export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-runtime-service"; -export { createNumericShortcutRuntimeService } from "./numeric-shortcut-runtime-service"; -export { createTokenizerDepsRuntimeService } from "./tokenizer-deps-runtime-service"; -export { runOverlayShortcutLocalFallbackRuntimeService } from "./shortcut-ui-deps-runtime-service"; -export { createRuntimeOptionsManagerRuntimeService } from "./runtime-options-manager-runtime-service"; -export { createAppLoggingRuntimeService } from "./app-logging-runtime-service"; -export { - createMecabTokenizerAndCheckRuntimeService, - createSubtitleTimingTrackerRuntimeService, -} from "./startup-resource-runtime-service"; -export { runGenerateConfigFlowRuntimeService } from "./config-generation-runtime-service"; -export { runStartupBootstrapRuntimeService } from "./startup-bootstrap-runtime-service"; -export { runSubsyncManualFromIpcRuntimeService, triggerSubsyncFromConfigRuntimeService } from "./subsync-runtime-service"; -export { updateInvisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService } from "./overlay-visibility-service"; -export { registerAnkiJimakuIpcRuntimeService } from "./anki-jimaku-runtime-service"; -export { createOverlayManagerService } from "./overlay-manager-service"; +} from "./overlay-manager-service"; diff --git a/src/core/services/ipc-deps-runtime-service.test.ts b/src/core/services/ipc-deps-runtime-service.test.ts deleted file mode 100644 index 66650db..0000000 --- a/src/core/services/ipc-deps-runtime-service.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createIpcDepsRuntimeService } from "./ipc-deps-runtime-service"; - -test("createIpcDepsRuntimeService maps window and mecab helpers", async () => { - let ignoreMouse: { ignore: boolean; forward?: boolean } | null = null; - let toggledDevTools = 0; - let mecabEnabled: boolean | null = null; - - const visibleWindow = { - isDestroyed: () => false, - setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { - ignoreMouse = { ignore, forward: options?.forward }; - }, - webContents: { - toggleDevTools: () => { - toggledDevTools += 1; - }, - }, - }; - - const deps = createIpcDepsRuntimeService({ - getInvisibleWindow: () => visibleWindow, - getMainWindow: () => visibleWindow, - getVisibleOverlayVisibility: () => true, - getInvisibleOverlayVisibility: () => false, - onOverlayModalClosed: () => {}, - openYomitanSettings: () => {}, - quitApp: () => {}, - toggleVisibleOverlay: () => {}, - tokenizeCurrentSubtitle: async () => ({ text: "x" }), - getCurrentSubtitleAss: () => "ass", - getMpvSubtitleRenderMetrics: () => ({ subPos: 100 }), - getSubtitlePosition: () => ({ x: 1, y: 2 }), - getSubtitleStyle: () => null, - saveSubtitlePosition: () => {}, - getMecabTokenizer: () => ({ - getStatus: () => ({ available: true, enabled: true, path: "/usr/bin/mecab" }), - setEnabled: (enabled: boolean) => { - mecabEnabled = enabled; - }, - }), - handleMpvCommand: () => {}, - getKeybindings: () => ({ copySubtitle: ["C"] }), - getSecondarySubMode: () => "hidden", - getMpvClient: () => ({ currentSecondarySubText: "secondary" }), - runSubsyncManual: async () => ({ ok: true }), - getAnkiConnectStatus: () => true, - getRuntimeOptions: () => ({ values: {} }), - setRuntimeOption: () => ({ ok: true }), - cycleRuntimeOption: () => ({ ok: true }), - }); - - deps.setInvisibleIgnoreMouseEvents(true, { forward: true }); - deps.toggleDevTools(); - deps.setMecabEnabled(false); - - assert.deepEqual(ignoreMouse, { ignore: true, forward: true }); - assert.equal(toggledDevTools, 1); - assert.equal(mecabEnabled, false); - assert.deepEqual(deps.getMecabStatus(), { - available: true, - enabled: true, - path: "/usr/bin/mecab", - }); - assert.equal(deps.getCurrentSecondarySub(), "secondary"); - assert.deepEqual(await deps.tokenizeCurrentSubtitle(), { text: "x" }); -}); - -test("createIpcDepsRuntimeService handles missing optional runtime resources", () => { - const deps = createIpcDepsRuntimeService({ - getInvisibleWindow: () => null, - getMainWindow: () => null, - getVisibleOverlayVisibility: () => false, - getInvisibleOverlayVisibility: () => false, - onOverlayModalClosed: () => {}, - openYomitanSettings: () => {}, - quitApp: () => {}, - toggleVisibleOverlay: () => {}, - tokenizeCurrentSubtitle: async () => null, - getCurrentSubtitleAss: () => "", - getMpvSubtitleRenderMetrics: () => null, - getSubtitlePosition: () => null, - getSubtitleStyle: () => null, - saveSubtitlePosition: () => {}, - getMecabTokenizer: () => null, - handleMpvCommand: () => {}, - getKeybindings: () => null, - getSecondarySubMode: () => "hidden", - getMpvClient: () => null, - runSubsyncManual: async () => ({ ok: false }), - getAnkiConnectStatus: () => false, - getRuntimeOptions: () => null, - setRuntimeOption: () => ({ ok: false }), - cycleRuntimeOption: () => ({ ok: false }), - }); - - deps.setInvisibleIgnoreMouseEvents(true, { forward: true }); - deps.toggleDevTools(); - deps.setMecabEnabled(true); - - assert.deepEqual(deps.getMecabStatus(), { - available: false, - enabled: false, - path: null, - }); - assert.equal(deps.getCurrentSecondarySub(), ""); -}); diff --git a/src/core/services/ipc-deps-runtime-service.ts b/src/core/services/ipc-deps-runtime-service.ts deleted file mode 100644 index 24ff129..0000000 --- a/src/core/services/ipc-deps-runtime-service.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { IpcServiceDeps } from "./ipc-service"; - -interface WindowLike { - isDestroyed: () => boolean; - setIgnoreMouseEvents: ( - ignore: boolean, - options?: { forward?: boolean }, - ) => void; - webContents: { - toggleDevTools: () => void; - }; -} - -interface MecabTokenizerLike { - getStatus: () => { available: boolean; enabled: boolean; path: string | null }; - setEnabled: (enabled: boolean) => void; -} - -interface MpvClientLike { - currentSecondarySubText?: string; -} - -export interface IpcDepsRuntimeOptions { - getInvisibleWindow: () => WindowLike | null; - getMainWindow: () => WindowLike | null; - getVisibleOverlayVisibility: () => boolean; - getInvisibleOverlayVisibility: () => boolean; - onOverlayModalClosed: (modal: string) => void; - openYomitanSettings: () => void; - quitApp: () => void; - toggleVisibleOverlay: () => void; - tokenizeCurrentSubtitle: () => Promise; - getCurrentSubtitleAss: () => string; - getMpvSubtitleRenderMetrics: () => unknown; - getSubtitlePosition: () => unknown; - getSubtitleStyle: () => unknown; - saveSubtitlePosition: (position: unknown) => void; - getMecabTokenizer: () => MecabTokenizerLike | null; - handleMpvCommand: (command: Array) => void; - getKeybindings: () => unknown; - getSecondarySubMode: () => unknown; - getMpvClient: () => MpvClientLike | null; - runSubsyncManual: (request: unknown) => Promise; - getAnkiConnectStatus: () => boolean; - getRuntimeOptions: () => unknown; - setRuntimeOption: (id: string, value: unknown) => unknown; - cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; -} - -export function createIpcDepsRuntimeService( - options: IpcDepsRuntimeOptions, -): IpcServiceDeps { - return { - getInvisibleWindow: () => options.getInvisibleWindow() as never, - isVisibleOverlayVisible: options.getVisibleOverlayVisibility, - setInvisibleIgnoreMouseEvents: (ignore, eventsOptions) => { - const invisibleWindow = options.getInvisibleWindow(); - if (!invisibleWindow || invisibleWindow.isDestroyed()) return; - invisibleWindow.setIgnoreMouseEvents(ignore, eventsOptions); - }, - onOverlayModalClosed: options.onOverlayModalClosed, - openYomitanSettings: options.openYomitanSettings, - quitApp: options.quitApp, - toggleDevTools: () => { - const mainWindow = options.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - mainWindow.webContents.toggleDevTools(); - }, - getVisibleOverlayVisibility: options.getVisibleOverlayVisibility, - toggleVisibleOverlay: options.toggleVisibleOverlay, - getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility, - tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, - getCurrentSubtitleAss: options.getCurrentSubtitleAss, - getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics, - getSubtitlePosition: options.getSubtitlePosition, - getSubtitleStyle: options.getSubtitleStyle, - saveSubtitlePosition: options.saveSubtitlePosition, - getMecabStatus: () => { - const mecabTokenizer = options.getMecabTokenizer(); - return mecabTokenizer - ? mecabTokenizer.getStatus() - : { available: false, enabled: false, path: null }; - }, - setMecabEnabled: (enabled) => { - const mecabTokenizer = options.getMecabTokenizer(); - if (!mecabTokenizer) return; - mecabTokenizer.setEnabled(enabled); - }, - handleMpvCommand: options.handleMpvCommand, - getKeybindings: options.getKeybindings, - getSecondarySubMode: options.getSecondarySubMode, - getCurrentSecondarySub: () => - options.getMpvClient()?.currentSecondarySubText || "", - runSubsyncManual: options.runSubsyncManual, - getAnkiConnectStatus: options.getAnkiConnectStatus, - getRuntimeOptions: options.getRuntimeOptions, - setRuntimeOption: options.setRuntimeOption, - cycleRuntimeOption: options.cycleRuntimeOption, - }; -} diff --git a/src/core/services/ipc-service.ts b/src/core/services/ipc-service.ts index 4801fdd..137afa4 100644 --- a/src/core/services/ipc-service.ts +++ b/src/core/services/ipc-service.ts @@ -1,7 +1,7 @@ import { BrowserWindow, ipcMain, IpcMainEvent } from "electron"; export interface IpcServiceDeps { - getInvisibleWindow: () => BrowserWindow | null; + getInvisibleWindow: () => WindowLike | null; isVisibleOverlayVisible: () => boolean; setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; onOverlayModalClosed: (modal: string) => void; @@ -30,6 +30,105 @@ export interface IpcServiceDeps { cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; } +interface WindowLike { + isDestroyed: () => boolean; + setIgnoreMouseEvents: ( + ignore: boolean, + options?: { forward?: boolean }, + ) => void; + webContents: { + toggleDevTools: () => void; + }; +} + +interface MecabTokenizerLike { + getStatus: () => { available: boolean; enabled: boolean; path: string | null }; + setEnabled: (enabled: boolean) => void; +} + +interface MpvClientLike { + currentSecondarySubText?: string; +} + +export interface IpcDepsRuntimeOptions { + getInvisibleWindow: () => WindowLike | null; + getMainWindow: () => WindowLike | null; + getVisibleOverlayVisibility: () => boolean; + getInvisibleOverlayVisibility: () => boolean; + onOverlayModalClosed: (modal: string) => void; + openYomitanSettings: () => void; + quitApp: () => void; + toggleVisibleOverlay: () => void; + tokenizeCurrentSubtitle: () => Promise; + getCurrentSubtitleAss: () => string; + getMpvSubtitleRenderMetrics: () => unknown; + getSubtitlePosition: () => unknown; + getSubtitleStyle: () => unknown; + saveSubtitlePosition: (position: unknown) => void; + getMecabTokenizer: () => MecabTokenizerLike | null; + handleMpvCommand: (command: Array) => void; + getKeybindings: () => unknown; + getSecondarySubMode: () => unknown; + getMpvClient: () => MpvClientLike | null; + runSubsyncManual: (request: unknown) => Promise; + getAnkiConnectStatus: () => boolean; + getRuntimeOptions: () => unknown; + setRuntimeOption: (id: string, value: unknown) => unknown; + cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; +} + +export function createIpcDepsRuntimeService( + options: IpcDepsRuntimeOptions, +): IpcServiceDeps { + return { + getInvisibleWindow: () => options.getInvisibleWindow(), + isVisibleOverlayVisible: options.getVisibleOverlayVisibility, + setInvisibleIgnoreMouseEvents: (ignore, eventsOptions) => { + const invisibleWindow = options.getInvisibleWindow(); + if (!invisibleWindow || invisibleWindow.isDestroyed()) return; + invisibleWindow.setIgnoreMouseEvents(ignore, eventsOptions); + }, + onOverlayModalClosed: options.onOverlayModalClosed, + openYomitanSettings: options.openYomitanSettings, + quitApp: options.quitApp, + toggleDevTools: () => { + const mainWindow = options.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.toggleDevTools(); + }, + getVisibleOverlayVisibility: options.getVisibleOverlayVisibility, + toggleVisibleOverlay: options.toggleVisibleOverlay, + getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility, + tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, + getCurrentSubtitleAss: options.getCurrentSubtitleAss, + getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics, + getSubtitlePosition: options.getSubtitlePosition, + getSubtitleStyle: options.getSubtitleStyle, + saveSubtitlePosition: options.saveSubtitlePosition, + getMecabStatus: () => { + const mecabTokenizer = options.getMecabTokenizer(); + return mecabTokenizer + ? mecabTokenizer.getStatus() + : { available: false, enabled: false, path: null }; + }, + setMecabEnabled: (enabled) => { + const mecabTokenizer = options.getMecabTokenizer(); + if (!mecabTokenizer) return; + mecabTokenizer.setEnabled(enabled); + }, + handleMpvCommand: options.handleMpvCommand, + getKeybindings: options.getKeybindings, + getSecondarySubMode: options.getSecondarySubMode, + getCurrentSecondarySub: () => + options.getMpvClient()?.currentSecondarySubText || "", + runSubsyncManual: options.runSubsyncManual, + getAnkiConnectStatus: options.getAnkiConnectStatus, + getRuntimeOptions: options.getRuntimeOptions, + setRuntimeOption: options.setRuntimeOption, + cycleRuntimeOption: options.cycleRuntimeOption, + }; +} + export function registerIpcHandlersService(deps: IpcServiceDeps): void { ipcMain.on( "set-ignore-mouse-events", diff --git a/src/core/services/jimaku-runtime-service.ts b/src/core/services/jimaku-service.ts similarity index 100% rename from src/core/services/jimaku-runtime-service.ts rename to src/core/services/jimaku-service.ts diff --git a/src/core/services/mining-runtime-service.ts b/src/core/services/mining-service.ts similarity index 100% rename from src/core/services/mining-runtime-service.ts rename to src/core/services/mining-service.ts diff --git a/src/core/services/mpv-runtime-service.test.ts b/src/core/services/mpv-control-service.test.ts similarity index 98% rename from src/core/services/mpv-runtime-service.test.ts rename to src/core/services/mpv-control-service.test.ts index 64c3fab..5abf49e 100644 --- a/src/core/services/mpv-runtime-service.test.ts +++ b/src/core/services/mpv-control-service.test.ts @@ -6,7 +6,7 @@ import { sendMpvCommandRuntimeService, setMpvSubVisibilityRuntimeService, showMpvOsdRuntimeService, -} from "./mpv-runtime-service"; +} from "./mpv-control-service"; test("showMpvOsdRuntimeService sends show-text when connected", () => { const commands: (string | number)[][] = []; diff --git a/src/core/services/mpv-runtime-service.ts b/src/core/services/mpv-control-service.ts similarity index 100% rename from src/core/services/mpv-runtime-service.ts rename to src/core/services/mpv-control-service.ts diff --git a/src/core/services/numeric-shortcut-runtime-service.test.ts b/src/core/services/numeric-shortcut-runtime-service.test.ts deleted file mode 100644 index 8351ecf..0000000 --- a/src/core/services/numeric-shortcut-runtime-service.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createNumericShortcutRuntimeService } from "./numeric-shortcut-runtime-service"; - -test("createNumericShortcutRuntimeService creates sessions wired to globalShortcut", () => { - const registered: string[] = []; - const unregistered: string[] = []; - const osd: string[] = []; - const handlers = new Map void>(); - - const runtime = createNumericShortcutRuntimeService({ - globalShortcut: { - register: (accelerator, callback) => { - registered.push(accelerator); - handlers.set(accelerator, callback); - return true; - }, - unregister: (accelerator) => { - unregistered.push(accelerator); - handlers.delete(accelerator); - }, - }, - showMpvOsd: (text) => { - osd.push(text); - }, - setTimer: () => setTimeout(() => {}, 1000), - clearTimer: (timer) => clearTimeout(timer), - }); - - const session = runtime.createSession(); - session.start({ - timeoutMs: 5000, - onDigit: () => {}, - messages: { - prompt: "Select count", - timeout: "Timed out", - }, - }); - - assert.equal(session.isActive(), true); - assert.ok(registered.includes("1")); - assert.ok(registered.includes("Escape")); - assert.equal(osd[0], "Select count"); - - handlers.get("Escape")?.(); - assert.equal(session.isActive(), false); - assert.ok(unregistered.includes("Escape")); -}); diff --git a/src/core/services/numeric-shortcut-runtime-service.ts b/src/core/services/numeric-shortcut-runtime-service.ts deleted file mode 100644 index 48140fd..0000000 --- a/src/core/services/numeric-shortcut-runtime-service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - createNumericShortcutSessionService, -} from "./numeric-shortcut-session-service"; - -interface GlobalShortcutLike { - register: (accelerator: string, callback: () => void) => boolean; - unregister: (accelerator: string) => void; -} - -export interface NumericShortcutRuntimeOptions { - globalShortcut: GlobalShortcutLike; - showMpvOsd: (text: string) => void; - setTimer: ( - handler: () => void, - timeoutMs: number, - ) => ReturnType; - clearTimer: (timer: ReturnType) => void; -} - -export function createNumericShortcutRuntimeService( - options: NumericShortcutRuntimeOptions, -) { - const createSession = () => - createNumericShortcutSessionService({ - registerShortcut: (accelerator, handler) => - options.globalShortcut.register(accelerator, handler), - unregisterShortcut: (accelerator) => - options.globalShortcut.unregister(accelerator), - setTimer: options.setTimer, - clearTimer: options.clearTimer, - showMpvOsd: options.showMpvOsd, - }); - - return { - createSession, - }; -} diff --git a/src/core/services/numeric-shortcut-session-service.ts b/src/core/services/numeric-shortcut-service.ts similarity index 70% rename from src/core/services/numeric-shortcut-session-service.ts rename to src/core/services/numeric-shortcut-service.ts index 7b4d94d..c6fd1ea 100644 --- a/src/core/services/numeric-shortcut-session-service.ts +++ b/src/core/services/numeric-shortcut-service.ts @@ -1,3 +1,37 @@ +interface GlobalShortcutLike { + register: (accelerator: string, callback: () => void) => boolean; + unregister: (accelerator: string) => void; +} + +export interface NumericShortcutRuntimeOptions { + globalShortcut: GlobalShortcutLike; + showMpvOsd: (text: string) => void; + setTimer: ( + handler: () => void, + timeoutMs: number, + ) => ReturnType; + clearTimer: (timer: ReturnType) => void; +} + +export function createNumericShortcutRuntimeService( + options: NumericShortcutRuntimeOptions, +) { + const createSession = () => + createNumericShortcutSessionService({ + registerShortcut: (accelerator, handler) => + options.globalShortcut.register(accelerator, handler), + unregisterShortcut: (accelerator) => + options.globalShortcut.unregister(accelerator), + setTimer: options.setTimer, + clearTimer: options.clearTimer, + showMpvOsd: options.showMpvOsd, + }); + + return { + createSession, + }; +} + export interface NumericShortcutSessionMessages { prompt: string; timeout: string; diff --git a/src/core/services/numeric-shortcut-session-service.test.ts b/src/core/services/numeric-shortcut-session-service.test.ts index 4002a37..c5d699a 100644 --- a/src/core/services/numeric-shortcut-session-service.test.ts +++ b/src/core/services/numeric-shortcut-session-service.test.ts @@ -1,6 +1,54 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { createNumericShortcutSessionService } from "./numeric-shortcut-session-service"; +import { + createNumericShortcutRuntimeService, + createNumericShortcutSessionService, +} from "./numeric-shortcut-service"; + +test("createNumericShortcutRuntimeService creates sessions wired to globalShortcut", () => { + const registered: string[] = []; + const unregistered: string[] = []; + const osd: string[] = []; + const handlers = new Map void>(); + + const runtime = createNumericShortcutRuntimeService({ + globalShortcut: { + register: (accelerator, callback) => { + registered.push(accelerator); + handlers.set(accelerator, callback); + return true; + }, + unregister: (accelerator) => { + unregistered.push(accelerator); + handlers.delete(accelerator); + }, + }, + showMpvOsd: (text) => { + osd.push(text); + }, + setTimer: () => setTimeout(() => {}, 1000), + clearTimer: (timer) => clearTimeout(timer), + }); + + const session = runtime.createSession(); + session.start({ + timeoutMs: 5000, + onDigit: () => {}, + messages: { + prompt: "Select count", + timeout: "Timed out", + }, + }); + + assert.equal(session.isActive(), true); + assert.ok(registered.includes("1")); + assert.ok(registered.includes("Escape")); + assert.equal(osd[0], "Select count"); + + handlers.get("Escape")?.(); + assert.equal(session.isActive(), false); + assert.ok(unregistered.includes("Escape")); +}); test("numeric shortcut session handles digit selection and unregisters shortcuts", () => { const handlers = new Map void>(); diff --git a/src/core/services/overlay-bridge-runtime-service.test.ts b/src/core/services/overlay-bridge-service.test.ts similarity index 98% rename from src/core/services/overlay-bridge-runtime-service.test.ts rename to src/core/services/overlay-bridge-service.test.ts index a9284bd..fafe405 100644 --- a/src/core/services/overlay-bridge-runtime-service.test.ts +++ b/src/core/services/overlay-bridge-service.test.ts @@ -4,7 +4,7 @@ import { KikuFieldGroupingChoice } from "../../types"; import { createFieldGroupingCallbackRuntimeService, sendToVisibleOverlayRuntimeService, -} from "./overlay-bridge-runtime-service"; +} from "./overlay-bridge-service"; test("sendToVisibleOverlayRuntimeService restores visibility flag when opening hidden overlay modal", () => { const sent: unknown[][] = []; diff --git a/src/core/services/overlay-bridge-runtime-service.ts b/src/core/services/overlay-bridge-service.ts similarity index 73% rename from src/core/services/overlay-bridge-runtime-service.ts rename to src/core/services/overlay-bridge-service.ts index 9e2d8eb..a705921 100644 --- a/src/core/services/overlay-bridge-runtime-service.ts +++ b/src/core/services/overlay-bridge-service.ts @@ -2,8 +2,6 @@ import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData, } from "../../types"; -import { addOverlayModalRestoreFlagService } from "./overlay-modal-restore-service"; -import { sendToVisibleOverlayService } from "./overlay-send-service"; import { createFieldGroupingCallbackService } from "./field-grouping-service"; import { BrowserWindow } from "electron"; @@ -16,19 +14,20 @@ export function sendToVisibleOverlayRuntimeService(options: { restoreOnModalClose?: T; restoreVisibleOverlayOnModalClose: Set; }): boolean { - return sendToVisibleOverlayService({ - mainWindow: options.mainWindow, - visibleOverlayVisible: options.visibleOverlayVisible, - setVisibleOverlayVisible: options.setVisibleOverlayVisible, - channel: options.channel, - payload: options.payload, - restoreOnModalClose: options.restoreOnModalClose, - addRestoreFlag: (modal) => - addOverlayModalRestoreFlagService( - options.restoreVisibleOverlayOnModalClose, - modal as T, - ), - }); + if (!options.mainWindow || options.mainWindow.isDestroyed()) return false; + const wasVisible = options.visibleOverlayVisible; + if (!options.visibleOverlayVisible) { + options.setVisibleOverlayVisible(true); + } + if (!wasVisible && options.restoreOnModalClose) { + options.restoreVisibleOverlayOnModalClose.add(options.restoreOnModalClose); + } + if (options.payload === undefined) { + options.mainWindow.webContents.send(options.channel); + } else { + options.mainWindow.webContents.send(options.channel, options.payload); + } + return true; } export function createFieldGroupingCallbackRuntimeService( diff --git a/src/core/services/overlay-broadcast-runtime-service.test.ts b/src/core/services/overlay-broadcast-runtime-service.test.ts deleted file mode 100644 index 08995d0..0000000 --- a/src/core/services/overlay-broadcast-runtime-service.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - broadcastRuntimeOptionsChangedRuntimeService, - broadcastToOverlayWindowsRuntimeService, - getOverlayWindowsRuntimeService, - setOverlayDebugVisualizationEnabledRuntimeService, -} from "./overlay-broadcast-runtime-service"; - -test("getOverlayWindowsRuntimeService returns non-destroyed windows only", () => { - const alive = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; - const dead = { isDestroyed: () => true } as unknown as Electron.BrowserWindow; - const windows = getOverlayWindowsRuntimeService({ - mainWindow: alive, - invisibleWindow: dead, - }); - assert.deepEqual(windows, [alive]); -}); - -test("broadcastToOverlayWindowsRuntimeService sends channel to each window", () => { - const calls: unknown[][] = []; - const window = { - webContents: { - send: (...args: unknown[]) => { - calls.push(args); - }, - }, - } as unknown as Electron.BrowserWindow; - broadcastToOverlayWindowsRuntimeService([window], "x", 1, "a"); - assert.deepEqual(calls, [["x", 1, "a"]]); -}); - -test("runtime-option and debug broadcasts use expected channels", () => { - const broadcasts: unknown[][] = []; - broadcastRuntimeOptionsChangedRuntimeService( - () => [], - (channel, ...args) => { - broadcasts.push([channel, ...args]); - }, - ); - let state = false; - const changed = setOverlayDebugVisualizationEnabledRuntimeService( - state, - true, - (enabled) => { - state = enabled; - }, - (channel, ...args) => { - broadcasts.push([channel, ...args]); - }, - ); - assert.equal(changed, true); - assert.equal(state, true); - assert.deepEqual(broadcasts, [ - ["runtime-options:changed", []], - ["overlay-debug-visualization:set", true], - ]); -}); diff --git a/src/core/services/overlay-broadcast-runtime-service.ts b/src/core/services/overlay-broadcast-runtime-service.ts deleted file mode 100644 index 6c44506..0000000 --- a/src/core/services/overlay-broadcast-runtime-service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { BrowserWindow } from "electron"; -import { RuntimeOptionState } from "../../types"; - -export function getOverlayWindowsRuntimeService(options: { - mainWindow: BrowserWindow | null; - invisibleWindow: BrowserWindow | null; -}): BrowserWindow[] { - const windows: BrowserWindow[] = []; - if (options.mainWindow && !options.mainWindow.isDestroyed()) { - windows.push(options.mainWindow); - } - if (options.invisibleWindow && !options.invisibleWindow.isDestroyed()) { - windows.push(options.invisibleWindow); - } - return windows; -} - -export function broadcastToOverlayWindowsRuntimeService( - windows: BrowserWindow[], - channel: string, - ...args: unknown[] -): void { - for (const window of windows) { - window.webContents.send(channel, ...args); - } -} - -export function broadcastRuntimeOptionsChangedRuntimeService( - getRuntimeOptionsState: () => RuntimeOptionState[], - broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, -): void { - broadcastToOverlayWindows("runtime-options:changed", getRuntimeOptionsState()); -} - -export function setOverlayDebugVisualizationEnabledRuntimeService( - currentEnabled: boolean, - nextEnabled: boolean, - setState: (enabled: boolean) => void, - broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, -): boolean { - if (currentEnabled === nextEnabled) return false; - setState(nextEnabled); - broadcastToOverlayWindows("overlay-debug-visualization:set", nextEnabled); - return true; -} diff --git a/src/core/services/overlay-manager-service.test.ts b/src/core/services/overlay-manager-service.test.ts index 57565d4..1b92694 100644 --- a/src/core/services/overlay-manager-service.test.ts +++ b/src/core/services/overlay-manager-service.test.ts @@ -1,6 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { createOverlayManagerService } from "./overlay-manager-service"; +import { + broadcastRuntimeOptionsChangedRuntimeService, + createOverlayManagerService, + setOverlayDebugVisualizationEnabledRuntimeService, +} from "./overlay-manager-service"; test("overlay manager initializes with empty windows and hidden overlays", () => { const manager = createOverlayManagerService(); @@ -40,3 +44,55 @@ test("overlay manager stores visibility state", () => { assert.equal(manager.getVisibleOverlayVisible(), true); assert.equal(manager.getInvisibleOverlayVisible(), true); }); + +test("overlay manager broadcasts to non-destroyed windows", () => { + const manager = createOverlayManagerService(); + const calls: unknown[][] = []; + const aliveWindow = { + isDestroyed: () => false, + webContents: { + send: (...args: unknown[]) => { + calls.push(args); + }, + }, + } as unknown as Electron.BrowserWindow; + const deadWindow = { + isDestroyed: () => true, + webContents: { + send: (..._args: unknown[]) => {}, + }, + } as unknown as Electron.BrowserWindow; + + manager.setMainWindow(aliveWindow); + manager.setInvisibleWindow(deadWindow); + manager.broadcastToOverlayWindows("x", 1, "a"); + + assert.deepEqual(calls, [["x", 1, "a"]]); +}); + +test("runtime-option and debug broadcasts use expected channels", () => { + const broadcasts: unknown[][] = []; + broadcastRuntimeOptionsChangedRuntimeService( + () => [], + (channel, ...args) => { + broadcasts.push([channel, ...args]); + }, + ); + let state = false; + const changed = setOverlayDebugVisualizationEnabledRuntimeService( + state, + true, + (enabled) => { + state = enabled; + }, + (channel, ...args) => { + broadcasts.push([channel, ...args]); + }, + ); + assert.equal(changed, true); + assert.equal(state, true); + assert.deepEqual(broadcasts, [ + ["runtime-options:changed", []], + ["overlay-debug-visualization:set", true], + ]); +}); diff --git a/src/core/services/overlay-manager-service.ts b/src/core/services/overlay-manager-service.ts index 05344da..401cde3 100644 --- a/src/core/services/overlay-manager-service.ts +++ b/src/core/services/overlay-manager-service.ts @@ -1,4 +1,5 @@ import { BrowserWindow } from "electron"; +import { RuntimeOptionState } from "../../types"; export interface OverlayManagerService { getMainWindow: () => BrowserWindow | null; @@ -10,6 +11,7 @@ export interface OverlayManagerService { getInvisibleOverlayVisible: () => boolean; setInvisibleOverlayVisible: (visible: boolean) => void; getOverlayWindows: () => BrowserWindow[]; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; } export function createOverlayManagerService(): OverlayManagerService { @@ -45,5 +47,36 @@ export function createOverlayManagerService(): OverlayManagerService { } return windows; }, + broadcastToOverlayWindows: (channel, ...args) => { + const windows: BrowserWindow[] = []; + if (mainWindow && !mainWindow.isDestroyed()) { + windows.push(mainWindow); + } + if (invisibleWindow && !invisibleWindow.isDestroyed()) { + windows.push(invisibleWindow); + } + for (const window of windows) { + window.webContents.send(channel, ...args); + } + }, }; } + +export function broadcastRuntimeOptionsChangedRuntimeService( + getRuntimeOptionsState: () => RuntimeOptionState[], + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, +): void { + broadcastToOverlayWindows("runtime-options:changed", getRuntimeOptionsState()); +} + +export function setOverlayDebugVisualizationEnabledRuntimeService( + currentEnabled: boolean, + nextEnabled: boolean, + setState: (enabled: boolean) => void, + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, +): boolean { + if (currentEnabled === nextEnabled) return false; + setState(nextEnabled); + broadcastToOverlayWindows("overlay-debug-visualization:set", nextEnabled); + return true; +} diff --git a/src/core/services/overlay-modal-restore-service.test.ts b/src/core/services/overlay-modal-restore-service.test.ts deleted file mode 100644 index 89762ba..0000000 --- a/src/core/services/overlay-modal-restore-service.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - addOverlayModalRestoreFlagService, - handleOverlayModalClosedService, -} from "./overlay-modal-restore-service"; - -test("overlay modal restore service adds modal restore flag", () => { - const restore = new Set<"runtime-options" | "subsync">(); - addOverlayModalRestoreFlagService(restore, "runtime-options"); - assert.equal(restore.has("runtime-options"), true); -}); - -test("overlay modal restore service hides overlay only when last modal closes", () => { - const restore = new Set<"runtime-options" | "subsync">(); - const visibility: boolean[] = []; - - addOverlayModalRestoreFlagService(restore, "runtime-options"); - addOverlayModalRestoreFlagService(restore, "subsync"); - - handleOverlayModalClosedService(restore, "runtime-options", (visible) => { - visibility.push(visible); - }); - assert.equal(visibility.length, 0); - - handleOverlayModalClosedService(restore, "subsync", (visible) => { - visibility.push(visible); - }); - assert.deepEqual(visibility, [false]); -}); diff --git a/src/core/services/overlay-modal-restore-service.ts b/src/core/services/overlay-modal-restore-service.ts deleted file mode 100644 index 1630ce9..0000000 --- a/src/core/services/overlay-modal-restore-service.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function addOverlayModalRestoreFlagService( - restoreSet: Set, - modal: T, -): void { - restoreSet.add(modal); -} - -export function handleOverlayModalClosedService( - restoreSet: Set, - modal: T, - setVisibleOverlayVisible: (visible: boolean) => void, -): void { - if (!restoreSet.has(modal)) return; - restoreSet.delete(modal); - if (restoreSet.size === 0) { - setVisibleOverlayVisible(false); - } -} diff --git a/src/core/services/overlay-send-service.ts b/src/core/services/overlay-send-service.ts deleted file mode 100644 index 4bc75d9..0000000 --- a/src/core/services/overlay-send-service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { BrowserWindow } from "electron"; - -export function sendToVisibleOverlayService(options: { - mainWindow: BrowserWindow | null; - visibleOverlayVisible: boolean; - setVisibleOverlayVisible: (visible: boolean) => void; - channel: string; - payload?: unknown; - restoreOnModalClose?: string; - addRestoreFlag: (modal: string) => void; -}): boolean { - if (!options.mainWindow || options.mainWindow.isDestroyed()) return false; - const wasVisible = options.visibleOverlayVisible; - if (!options.visibleOverlayVisible) { - options.setVisibleOverlayVisible(true); - } - if (!wasVisible && options.restoreOnModalClose) { - options.addRestoreFlag(options.restoreOnModalClose); - } - if (options.payload === undefined) { - options.mainWindow.webContents.send(options.channel); - } else { - options.mainWindow.webContents.send(options.channel, options.payload); - } - return true; -} diff --git a/src/core/services/overlay-shortcut-fallback-runner.ts b/src/core/services/overlay-shortcut-fallback-runner.ts deleted file mode 100644 index 859bbf0..0000000 --- a/src/core/services/overlay-shortcut-fallback-runner.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ConfiguredShortcuts } from "../utils/shortcut-config"; - -export interface OverlayShortcutFallbackHandlers { - openRuntimeOptions: () => void; - openJimaku: () => void; - markAudioCard: () => void; - copySubtitleMultiple: (timeoutMs: number) => void; - copySubtitle: () => void; - toggleSecondarySub: () => void; - updateLastCardFromClipboard: () => void; - triggerFieldGrouping: () => void; - triggerSubsync: () => void; - mineSentence: () => void; - mineSentenceMultiple: (timeoutMs: number) => void; -} - -export function runOverlayShortcutLocalFallback( - input: Electron.Input, - shortcuts: ConfiguredShortcuts, - matcher: ( - input: Electron.Input, - accelerator: string, - allowWhenRegistered?: boolean, - ) => boolean, - handlers: OverlayShortcutFallbackHandlers, -): boolean { - const actions: Array<{ - accelerator: string | null | undefined; - run: () => void; - allowWhenRegistered?: boolean; - }> = [ - { - accelerator: shortcuts.openRuntimeOptions, - run: () => { - handlers.openRuntimeOptions(); - }, - }, - { - accelerator: shortcuts.openJimaku, - run: () => { - handlers.openJimaku(); - }, - }, - { - accelerator: shortcuts.markAudioCard, - run: () => { - handlers.markAudioCard(); - }, - }, - { - accelerator: shortcuts.copySubtitleMultiple, - run: () => { - handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs); - }, - }, - { - accelerator: shortcuts.copySubtitle, - run: () => { - handlers.copySubtitle(); - }, - }, - { - accelerator: shortcuts.toggleSecondarySub, - run: () => handlers.toggleSecondarySub(), - allowWhenRegistered: true, - }, - { - accelerator: shortcuts.updateLastCardFromClipboard, - run: () => { - handlers.updateLastCardFromClipboard(); - }, - }, - { - accelerator: shortcuts.triggerFieldGrouping, - run: () => { - handlers.triggerFieldGrouping(); - }, - }, - { - accelerator: shortcuts.triggerSubsync, - run: () => { - handlers.triggerSubsync(); - }, - }, - { - accelerator: shortcuts.mineSentence, - run: () => { - handlers.mineSentence(); - }, - }, - { - accelerator: shortcuts.mineSentenceMultiple, - run: () => { - handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs); - }, - }, - ]; - - for (const action of actions) { - if (!action.accelerator) continue; - if ( - matcher( - input, - action.accelerator, - action.allowWhenRegistered === true, - ) - ) { - action.run(); - return true; - } - } - - return false; -} diff --git a/src/core/services/overlay-shortcut-runtime-service.ts b/src/core/services/overlay-shortcut-handler.ts similarity index 53% rename from src/core/services/overlay-shortcut-runtime-service.ts rename to src/core/services/overlay-shortcut-handler.ts index 3a241f9..de3e740 100644 --- a/src/core/services/overlay-shortcut-runtime-service.ts +++ b/src/core/services/overlay-shortcut-handler.ts @@ -1,8 +1,20 @@ -import { - OverlayShortcutFallbackHandlers, -} from "./overlay-shortcut-fallback-runner"; +import { ConfiguredShortcuts } from "../utils/shortcut-config"; import { OverlayShortcutHandlers } from "./overlay-shortcut-service"; +export interface OverlayShortcutFallbackHandlers { + openRuntimeOptions: () => void; + openJimaku: () => void; + markAudioCard: () => void; + copySubtitleMultiple: (timeoutMs: number) => void; + copySubtitle: () => void; + toggleSecondarySub: () => void; + updateLastCardFromClipboard: () => void; + triggerFieldGrouping: () => void; + triggerSubsync: () => void; + mineSentence: () => void; + mineSentenceMultiple: (timeoutMs: number) => void; +} + export interface OverlayShortcutRuntimeDeps { showMpvOsd: (text: string) => void; openRuntimeOptions: () => void; @@ -103,3 +115,102 @@ export function createOverlayShortcutRuntimeHandlers( return { overlayHandlers, fallbackHandlers }; } + +export function runOverlayShortcutLocalFallback( + input: Electron.Input, + shortcuts: ConfiguredShortcuts, + matcher: ( + input: Electron.Input, + accelerator: string, + allowWhenRegistered?: boolean, + ) => boolean, + handlers: OverlayShortcutFallbackHandlers, +): boolean { + const actions: Array<{ + accelerator: string | null | undefined; + run: () => void; + allowWhenRegistered?: boolean; + }> = [ + { + accelerator: shortcuts.openRuntimeOptions, + run: () => { + handlers.openRuntimeOptions(); + }, + }, + { + accelerator: shortcuts.openJimaku, + run: () => { + handlers.openJimaku(); + }, + }, + { + accelerator: shortcuts.markAudioCard, + run: () => { + handlers.markAudioCard(); + }, + }, + { + accelerator: shortcuts.copySubtitleMultiple, + run: () => { + handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs); + }, + }, + { + accelerator: shortcuts.copySubtitle, + run: () => { + handlers.copySubtitle(); + }, + }, + { + accelerator: shortcuts.toggleSecondarySub, + run: () => handlers.toggleSecondarySub(), + allowWhenRegistered: true, + }, + { + accelerator: shortcuts.updateLastCardFromClipboard, + run: () => { + handlers.updateLastCardFromClipboard(); + }, + }, + { + accelerator: shortcuts.triggerFieldGrouping, + run: () => { + handlers.triggerFieldGrouping(); + }, + }, + { + accelerator: shortcuts.triggerSubsync, + run: () => { + handlers.triggerSubsync(); + }, + }, + { + accelerator: shortcuts.mineSentence, + run: () => { + handlers.mineSentence(); + }, + }, + { + accelerator: shortcuts.mineSentenceMultiple, + run: () => { + handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs); + }, + }, + ]; + + for (const action of actions) { + if (!action.accelerator) continue; + if ( + matcher( + input, + action.accelerator, + action.allowWhenRegistered === true, + ) + ) { + action.run(); + return true; + } + } + + return false; +} diff --git a/src/core/services/overlay-shortcut-lifecycle-service.ts b/src/core/services/overlay-shortcut-lifecycle-service.ts deleted file mode 100644 index 2ce0514..0000000 --- a/src/core/services/overlay-shortcut-lifecycle-service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { OverlayShortcutHandlers, registerOverlayShortcutsService, unregisterOverlayShortcutsService } from "./overlay-shortcut-service"; -import { ConfiguredShortcuts } from "../utils/shortcut-config"; - -export interface OverlayShortcutLifecycleDeps { - getConfiguredShortcuts: () => ConfiguredShortcuts; - getOverlayHandlers: () => OverlayShortcutHandlers; - cancelPendingMultiCopy: () => void; - cancelPendingMineSentenceMultiple: () => void; -} - -export function registerOverlayShortcutsRuntimeService( - deps: OverlayShortcutLifecycleDeps, -): boolean { - return registerOverlayShortcutsService( - deps.getConfiguredShortcuts(), - deps.getOverlayHandlers(), - ); -} - -export function unregisterOverlayShortcutsRuntimeService( - shortcutsRegistered: boolean, - deps: OverlayShortcutLifecycleDeps, -): boolean { - if (!shortcutsRegistered) return shortcutsRegistered; - deps.cancelPendingMultiCopy(); - deps.cancelPendingMineSentenceMultiple(); - unregisterOverlayShortcutsService(deps.getConfiguredShortcuts()); - return false; -} - -export function syncOverlayShortcutsRuntimeService( - shouldBeActive: boolean, - shortcutsRegistered: boolean, - deps: OverlayShortcutLifecycleDeps, -): boolean { - if (shouldBeActive) { - return registerOverlayShortcutsRuntimeService(deps); - } - return unregisterOverlayShortcutsRuntimeService(shortcutsRegistered, deps); -} - -export function refreshOverlayShortcutsRuntimeService( - shouldBeActive: boolean, - shortcutsRegistered: boolean, - deps: OverlayShortcutLifecycleDeps, -): boolean { - const cleared = unregisterOverlayShortcutsRuntimeService( - shortcutsRegistered, - deps, - ); - return syncOverlayShortcutsRuntimeService(shouldBeActive, cleared, deps); -} diff --git a/src/core/services/overlay-shortcut-service.ts b/src/core/services/overlay-shortcut-service.ts index 7bab1ce..71d955a 100644 --- a/src/core/services/overlay-shortcut-service.ts +++ b/src/core/services/overlay-shortcut-service.ts @@ -16,6 +16,13 @@ export interface OverlayShortcutHandlers { openJimaku: () => void; } +export interface OverlayShortcutLifecycleDeps { + getConfiguredShortcuts: () => ConfiguredShortcuts; + getOverlayHandlers: () => OverlayShortcutHandlers; + cancelPendingMultiCopy: () => void; + cancelPendingMineSentenceMultiple: () => void; +} + export function registerOverlayShortcutsService( shortcuts: ConfiguredShortcuts, handlers: OverlayShortcutHandlers, @@ -167,3 +174,46 @@ export function unregisterOverlayShortcutsService( globalShortcut.unregister(shortcuts.openJimaku); } } + +export function registerOverlayShortcutsRuntimeService( + deps: OverlayShortcutLifecycleDeps, +): boolean { + return registerOverlayShortcutsService( + deps.getConfiguredShortcuts(), + deps.getOverlayHandlers(), + ); +} + +export function unregisterOverlayShortcutsRuntimeService( + shortcutsRegistered: boolean, + deps: OverlayShortcutLifecycleDeps, +): boolean { + if (!shortcutsRegistered) return shortcutsRegistered; + deps.cancelPendingMultiCopy(); + deps.cancelPendingMineSentenceMultiple(); + unregisterOverlayShortcutsService(deps.getConfiguredShortcuts()); + return false; +} + +export function syncOverlayShortcutsRuntimeService( + shouldBeActive: boolean, + shortcutsRegistered: boolean, + deps: OverlayShortcutLifecycleDeps, +): boolean { + if (shouldBeActive) { + return registerOverlayShortcutsRuntimeService(deps); + } + return unregisterOverlayShortcutsRuntimeService(shortcutsRegistered, deps); +} + +export function refreshOverlayShortcutsRuntimeService( + shouldBeActive: boolean, + shortcutsRegistered: boolean, + deps: OverlayShortcutLifecycleDeps, +): boolean { + const cleared = unregisterOverlayShortcutsRuntimeService( + shortcutsRegistered, + deps, + ); + return syncOverlayShortcutsRuntimeService(shouldBeActive, cleared, deps); +} diff --git a/src/core/services/overlay-visibility-runtime-service.ts b/src/core/services/overlay-visibility-runtime-service.ts deleted file mode 100644 index 53eecbe..0000000 --- a/src/core/services/overlay-visibility-runtime-service.ts +++ /dev/null @@ -1,46 +0,0 @@ -export function syncInvisibleOverlayMousePassthroughService(options: { - hasInvisibleWindow: () => boolean; - setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void; - visibleOverlayVisible: boolean; - invisibleOverlayVisible: boolean; -}): void { - if (!options.hasInvisibleWindow()) return; - if (options.visibleOverlayVisible) { - options.setIgnoreMouseEvents(true, { forward: true }); - } else if (options.invisibleOverlayVisible) { - options.setIgnoreMouseEvents(false); - } -} - -export function setVisibleOverlayVisibleService(options: { - visible: boolean; - setVisibleOverlayVisibleState: (visible: boolean) => void; - updateVisibleOverlayVisibility: () => void; - updateInvisibleOverlayVisibility: () => void; - syncInvisibleOverlayMousePassthrough: () => void; - shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; - isMpvConnected: () => boolean; - setMpvSubVisibility: (visible: boolean) => void; -}): void { - options.setVisibleOverlayVisibleState(options.visible); - options.updateVisibleOverlayVisibility(); - options.updateInvisibleOverlayVisibility(); - options.syncInvisibleOverlayMousePassthrough(); - if ( - options.shouldBindVisibleOverlayToMpvSubVisibility() && - options.isMpvConnected() - ) { - options.setMpvSubVisibility(!options.visible); - } -} - -export function setInvisibleOverlayVisibleService(options: { - visible: boolean; - setInvisibleOverlayVisibleState: (visible: boolean) => void; - updateInvisibleOverlayVisibility: () => void; - syncInvisibleOverlayMousePassthrough: () => void; -}): void { - options.setInvisibleOverlayVisibleState(options.visible); - options.updateInvisibleOverlayVisibility(); - options.syncInvisibleOverlayMousePassthrough(); -} diff --git a/src/core/services/overlay-visibility-service.ts b/src/core/services/overlay-visibility-service.ts index 894410e..6fbc860 100644 --- a/src/core/services/overlay-visibility-service.ts +++ b/src/core/services/overlay-visibility-service.ts @@ -24,17 +24,11 @@ export function updateVisibleOverlayVisibilityService(args: { enforceOverlayLayerOrder: () => void; syncOverlayShortcuts: () => void; }): void { - console.log( - "updateVisibleOverlayVisibility called, visibleOverlayVisible:", - args.visibleOverlayVisible, - ); if (!args.mainWindow || args.mainWindow.isDestroyed()) { - console.log("mainWindow not available"); return; } if (!args.visibleOverlayVisible) { - console.log("Hiding visible overlay"); args.mainWindow.hide(); if ( @@ -57,11 +51,6 @@ export function updateVisibleOverlayVisibilityService(args: { return; } - console.log( - "Should show visible overlay, isTracking:", - args.windowTracker?.isTracking(), - ); - if (args.shouldBindVisibleOverlayToMpvSubVisibility && args.mpvConnected) { args.mpvSend({ command: ["get_property", "secondary-sub-visibility"], @@ -72,11 +61,9 @@ export function updateVisibleOverlayVisibilityService(args: { if (args.windowTracker && args.windowTracker.isTracking()) { args.setTrackerNotReadyWarningShown(false); const geometry = args.windowTracker.getGeometry(); - console.log("Geometry:", geometry); if (geometry) { args.updateOverlayBounds(geometry); } - console.log("Showing visible overlay mainWindow"); args.ensureOverlayWindowLevel(args.mainWindow); args.mainWindow.show(); args.mainWindow.focus(); @@ -96,9 +83,6 @@ export function updateVisibleOverlayVisibilityService(args: { } if (!args.trackerNotReadyWarningShown) { - console.warn( - "Window tracker exists but is not tracking yet; using fallback bounds until tracking starts", - ); args.setTrackerNotReadyWarningShown(true); } const cursorPoint = screen.getCursorScreenPoint(); @@ -181,3 +165,50 @@ export function updateInvisibleOverlayVisibilityService(args: { showInvisibleWithoutFocus(); args.syncOverlayShortcuts(); } + +export function syncInvisibleOverlayMousePassthroughService(options: { + hasInvisibleWindow: () => boolean; + setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void; + visibleOverlayVisible: boolean; + invisibleOverlayVisible: boolean; +}): void { + if (!options.hasInvisibleWindow()) return; + if (options.visibleOverlayVisible) { + options.setIgnoreMouseEvents(true, { forward: true }); + } else if (options.invisibleOverlayVisible) { + options.setIgnoreMouseEvents(false); + } +} + +export function setVisibleOverlayVisibleService(options: { + visible: boolean; + setVisibleOverlayVisibleState: (visible: boolean) => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isMpvConnected: () => boolean; + setMpvSubVisibility: (visible: boolean) => void; +}): void { + options.setVisibleOverlayVisibleState(options.visible); + options.updateVisibleOverlayVisibility(); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); + if ( + options.shouldBindVisibleOverlayToMpvSubVisibility() && + options.isMpvConnected() + ) { + options.setMpvSubVisibility(!options.visible); + } +} + +export function setInvisibleOverlayVisibleService(options: { + visible: boolean; + setInvisibleOverlayVisibleState: (visible: boolean) => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; +}): void { + options.setInvisibleOverlayVisibleState(options.visible); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); +} diff --git a/src/core/services/runtime-options-runtime-service.test.ts b/src/core/services/runtime-options-ipc-service.test.ts similarity index 97% rename from src/core/services/runtime-options-runtime-service.test.ts rename to src/core/services/runtime-options-ipc-service.test.ts index cd0b742..5d53634 100644 --- a/src/core/services/runtime-options-runtime-service.test.ts +++ b/src/core/services/runtime-options-ipc-service.test.ts @@ -4,7 +4,7 @@ import { applyRuntimeOptionResultRuntimeService, cycleRuntimeOptionFromIpcRuntimeService, setRuntimeOptionFromIpcRuntimeService, -} from "./runtime-options-runtime-service"; +} from "./runtime-options-ipc-service"; test("applyRuntimeOptionResultRuntimeService emits success OSD message", () => { const osd: string[] = []; diff --git a/src/core/services/runtime-options-runtime-service.ts b/src/core/services/runtime-options-ipc-service.ts similarity index 100% rename from src/core/services/runtime-options-runtime-service.ts rename to src/core/services/runtime-options-ipc-service.ts diff --git a/src/core/services/runtime-options-manager-runtime-service.test.ts b/src/core/services/runtime-options-manager-runtime-service.test.ts deleted file mode 100644 index a49bd17..0000000 --- a/src/core/services/runtime-options-manager-runtime-service.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createRuntimeOptionsManagerRuntimeService } from "./runtime-options-manager-runtime-service"; - -test("createRuntimeOptionsManagerRuntimeService wires patch + options changed callbacks", () => { - const patches: unknown[] = []; - const changedSnapshots: unknown[] = []; - const manager = createRuntimeOptionsManagerRuntimeService({ - getAnkiConfig: () => ({ - behavior: { autoUpdateNewCards: true }, - isKiku: { fieldGrouping: "manual" }, - }), - applyAnkiPatch: (patch) => { - patches.push(patch); - }, - onOptionsChanged: (options) => { - changedSnapshots.push(options); - }, - }); - - const result = manager.setOptionValue("anki.autoUpdateNewCards", false); - assert.equal(result.ok, true); - assert.equal(patches.length > 0, true); - assert.equal(changedSnapshots.length > 0, true); -}); diff --git a/src/core/services/runtime-options-manager-runtime-service.ts b/src/core/services/runtime-options-manager-runtime-service.ts deleted file mode 100644 index 0f550f4..0000000 --- a/src/core/services/runtime-options-manager-runtime-service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { RuntimeOptionsManager } from "../../runtime-options"; -import { AnkiConnectConfig, RuntimeOptionState } from "../../types"; - -export interface RuntimeOptionsManagerRuntimeDeps { - getAnkiConfig: () => AnkiConnectConfig; - applyAnkiPatch: (patch: Partial) => void; - onOptionsChanged: (options: RuntimeOptionState[]) => void; -} - -export function createRuntimeOptionsManagerRuntimeService( - deps: RuntimeOptionsManagerRuntimeDeps, -): RuntimeOptionsManager { - return new RuntimeOptionsManager(deps.getAnkiConfig, { - applyAnkiPatch: deps.applyAnkiPatch, - onOptionsChanged: deps.onOptionsChanged, - }); -} diff --git a/src/core/services/shortcut-ui-deps-runtime-service.test.ts b/src/core/services/shortcut-ui-deps-runtime-service.test.ts deleted file mode 100644 index 1e68496..0000000 --- a/src/core/services/shortcut-ui-deps-runtime-service.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - runOverlayShortcutLocalFallbackRuntimeService, -} from "./shortcut-ui-deps-runtime-service"; - -function makeOptions() { - return { - getConfiguredShortcuts: () => ({ - toggleVisibleOverlayGlobal: null, - toggleInvisibleOverlayGlobal: null, - copySubtitle: null, - copySubtitleMultiple: null, - updateLastCardFromClipboard: null, - triggerFieldGrouping: null, - triggerSubsync: null, - mineSentence: null, - mineSentenceMultiple: null, - multiCopyTimeoutMs: 5000, - toggleSecondarySub: null, - markAudioCard: null, - openRuntimeOptions: "Ctrl+R", - openJimaku: null, - }), - getOverlayShortcutFallbackHandlers: () => ({ - openRuntimeOptions: () => {}, - openJimaku: () => {}, - markAudioCard: () => {}, - copySubtitleMultiple: () => {}, - copySubtitle: () => {}, - toggleSecondarySub: () => {}, - updateLastCardFromClipboard: () => {}, - triggerFieldGrouping: () => {}, - triggerSubsync: () => {}, - mineSentence: () => {}, - mineSentenceMultiple: () => {}, - }), - shortcutMatcher: () => false, - }; -} - -test("runOverlayShortcutLocalFallbackRuntimeService delegates and returns boolean", () => { - const options = { - ...makeOptions(), - shortcutMatcher: () => true, - }; - - const handled = runOverlayShortcutLocalFallbackRuntimeService( - { - key: "r", - code: "KeyR", - alt: false, - control: true, - shift: false, - meta: false, - type: "keyDown", - } as Electron.Input, - options, - ); - - assert.equal(handled, true); -}); diff --git a/src/core/services/shortcut-ui-deps-runtime-service.ts b/src/core/services/shortcut-ui-deps-runtime-service.ts deleted file mode 100644 index 74d2742..0000000 --- a/src/core/services/shortcut-ui-deps-runtime-service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ConfiguredShortcuts } from "../utils/shortcut-config"; -import { OverlayShortcutFallbackHandlers, runOverlayShortcutLocalFallback } from "./overlay-shortcut-fallback-runner"; - -export interface ShortcutUiRuntimeDepsOptions { - getConfiguredShortcuts: () => ConfiguredShortcuts; - getOverlayShortcutFallbackHandlers: () => OverlayShortcutFallbackHandlers; - shortcutMatcher: ( - input: Electron.Input, - accelerator: string, - allowWhenRegistered?: boolean, - ) => boolean; -} - -export function runOverlayShortcutLocalFallbackRuntimeService( - input: Electron.Input, - options: ShortcutUiRuntimeDepsOptions, -): boolean { - return runOverlayShortcutLocalFallback( - input, - options.getConfiguredShortcuts(), - options.shortcutMatcher, - options.getOverlayShortcutFallbackHandlers(), - ); -} diff --git a/src/core/services/startup-bootstrap-runtime-service.ts b/src/core/services/startup-bootstrap-runtime-service.ts deleted file mode 100644 index 6ea1f0d..0000000 --- a/src/core/services/startup-bootstrap-runtime-service.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { CliArgs } from "../../cli/args"; - -export interface StartupBootstrapRuntimeState { - initialArgs: CliArgs; - mpvSocketPath: string; - texthookerPort: number; - backendOverride: string | null; - autoStartOverlay: boolean; - texthookerOnlyMode: boolean; -} - -export interface StartupBootstrapRuntimeDeps { - argv: string[]; - parseArgs: (argv: string[]) => CliArgs; - setLogLevelEnv: (level: string) => void; - enableVerboseLogging: () => void; - forceX11Backend: (args: CliArgs) => void; - enforceUnsupportedWaylandMode: (args: CliArgs) => void; - getDefaultSocketPath: () => string; - defaultTexthookerPort: number; - runGenerateConfigFlow: (args: CliArgs) => boolean; - startAppLifecycle: (args: CliArgs) => void; -} - -export function runStartupBootstrapRuntimeService( - deps: StartupBootstrapRuntimeDeps, -): StartupBootstrapRuntimeState { - const initialArgs = deps.parseArgs(deps.argv); - - if (initialArgs.logLevel) { - deps.setLogLevelEnv(initialArgs.logLevel); - } else if (initialArgs.verbose) { - deps.enableVerboseLogging(); - } - - deps.forceX11Backend(initialArgs); - deps.enforceUnsupportedWaylandMode(initialArgs); - - const state: StartupBootstrapRuntimeState = { - initialArgs, - mpvSocketPath: initialArgs.socketPath ?? deps.getDefaultSocketPath(), - texthookerPort: initialArgs.texthookerPort ?? deps.defaultTexthookerPort, - backendOverride: initialArgs.backend ?? null, - autoStartOverlay: initialArgs.autoStartOverlay, - texthookerOnlyMode: initialArgs.texthooker, - }; - - if (!deps.runGenerateConfigFlow(initialArgs)) { - deps.startAppLifecycle(initialArgs); - } - - return state; -} diff --git a/src/core/services/startup-bootstrap-runtime-service.test.ts b/src/core/services/startup-bootstrap-service.test.ts similarity index 98% rename from src/core/services/startup-bootstrap-runtime-service.test.ts rename to src/core/services/startup-bootstrap-service.test.ts index 97b457b..cb8408e 100644 --- a/src/core/services/startup-bootstrap-runtime-service.test.ts +++ b/src/core/services/startup-bootstrap-service.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { runStartupBootstrapRuntimeService, -} from "./startup-bootstrap-runtime-service"; +} from "./startup-service"; import { CliArgs } from "../../cli/args"; function makeArgs(overrides: Partial = {}): CliArgs { diff --git a/src/core/services/startup-resource-runtime-service.test.ts b/src/core/services/startup-resource-runtime-service.test.ts deleted file mode 100644 index 9474155..0000000 --- a/src/core/services/startup-resource-runtime-service.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - createMecabTokenizerAndCheckRuntimeService, - createSubtitleTimingTrackerRuntimeService, -} from "./startup-resource-runtime-service"; - -test("createMecabTokenizerAndCheckRuntimeService sets tokenizer and checks availability", async () => { - const calls: string[] = []; - let assigned: unknown = null; - await createMecabTokenizerAndCheckRuntimeService({ - createMecabTokenizer: () => ({ - checkAvailability: async () => { - calls.push("checkAvailability"); - }, - }), - setMecabTokenizer: (tokenizer) => { - assigned = tokenizer; - calls.push("setMecabTokenizer"); - }, - }); - assert.equal(assigned !== null, true); - assert.deepEqual(calls, ["setMecabTokenizer", "checkAvailability"]); -}); - -test("createSubtitleTimingTrackerRuntimeService sets created tracker", () => { - const tracker = { id: "x" }; - let assigned: unknown = null; - createSubtitleTimingTrackerRuntimeService({ - createSubtitleTimingTracker: () => tracker, - setSubtitleTimingTracker: (value) => { - assigned = value; - }, - }); - assert.equal(assigned, tracker); -}); diff --git a/src/core/services/startup-resource-runtime-service.ts b/src/core/services/startup-resource-runtime-service.ts deleted file mode 100644 index f82c946..0000000 --- a/src/core/services/startup-resource-runtime-service.ts +++ /dev/null @@ -1,26 +0,0 @@ -interface MecabTokenizerLike { - checkAvailability: () => Promise; -} - -interface SubtitleTimingTrackerLike {} - -export async function createMecabTokenizerAndCheckRuntimeService< - T extends MecabTokenizerLike, ->(options: { - createMecabTokenizer: () => T; - setMecabTokenizer: (tokenizer: T) => void; -}): Promise { - const tokenizer = options.createMecabTokenizer(); - options.setMecabTokenizer(tokenizer); - await tokenizer.checkAvailability(); -} - -export function createSubtitleTimingTrackerRuntimeService< - T extends SubtitleTimingTrackerLike, ->(options: { - createSubtitleTimingTracker: () => T; - setSubtitleTimingTracker: (tracker: T) => void; -}): void { - const tracker = options.createSubtitleTimingTracker(); - options.setSubtitleTimingTracker(tracker); -} diff --git a/src/core/services/app-ready-runtime-service.ts b/src/core/services/startup-service.ts similarity index 58% rename from src/core/services/app-ready-runtime-service.ts rename to src/core/services/startup-service.ts index 99372cc..469aa49 100644 --- a/src/core/services/app-ready-runtime-service.ts +++ b/src/core/services/startup-service.ts @@ -1,5 +1,58 @@ +import { CliArgs } from "../../cli/args"; import { ConfigValidationWarning, SecondarySubMode } from "../../types"; +export interface StartupBootstrapRuntimeState { + initialArgs: CliArgs; + mpvSocketPath: string; + texthookerPort: number; + backendOverride: string | null; + autoStartOverlay: boolean; + texthookerOnlyMode: boolean; +} + +export interface StartupBootstrapRuntimeDeps { + argv: string[]; + parseArgs: (argv: string[]) => CliArgs; + setLogLevelEnv: (level: string) => void; + enableVerboseLogging: () => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + getDefaultSocketPath: () => string; + defaultTexthookerPort: number; + runGenerateConfigFlow: (args: CliArgs) => boolean; + startAppLifecycle: (args: CliArgs) => void; +} + +export function runStartupBootstrapRuntimeService( + deps: StartupBootstrapRuntimeDeps, +): StartupBootstrapRuntimeState { + const initialArgs = deps.parseArgs(deps.argv); + + if (initialArgs.logLevel) { + deps.setLogLevelEnv(initialArgs.logLevel); + } else if (initialArgs.verbose) { + deps.enableVerboseLogging(); + } + + deps.forceX11Backend(initialArgs); + deps.enforceUnsupportedWaylandMode(initialArgs); + + const state: StartupBootstrapRuntimeState = { + initialArgs, + mpvSocketPath: initialArgs.socketPath ?? deps.getDefaultSocketPath(), + texthookerPort: initialArgs.texthookerPort ?? deps.defaultTexthookerPort, + backendOverride: initialArgs.backend ?? null, + autoStartOverlay: initialArgs.autoStartOverlay, + texthookerOnlyMode: initialArgs.texthooker, + }; + + if (!deps.runGenerateConfigFlow(initialArgs)) { + deps.startAppLifecycle(initialArgs); + } + + return state; +} + interface AppReadyConfigLike { secondarySub?: { defaultMode?: SecondarySubMode; @@ -55,7 +108,10 @@ export async function runAppReadyRuntimeService( const wsEnabled = wsConfig.enabled ?? "auto"; const wsPort = wsConfig.port || deps.defaultWebsocketPort; - if (wsEnabled === true || (wsEnabled === "auto" && !deps.hasMpvWebsocketPlugin())) { + if ( + wsEnabled === true || + (wsEnabled === "auto" && !deps.hasMpvWebsocketPlugin()) + ) { deps.startSubtitleWebsocket(wsPort); } else if (wsEnabled === "auto") { deps.log("mpv_websocket detected, skipping built-in WebSocket server"); diff --git a/src/core/services/subsync-runtime-service.ts b/src/core/services/subsync-runner-service.ts similarity index 100% rename from src/core/services/subsync-runtime-service.ts rename to src/core/services/subsync-runner-service.ts diff --git a/src/core/services/tokenizer-deps-runtime-service.test.ts b/src/core/services/tokenizer-deps-runtime-service.test.ts deleted file mode 100644 index 298fd62..0000000 --- a/src/core/services/tokenizer-deps-runtime-service.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { PartOfSpeech } from "../../types"; -import { createTokenizerDepsRuntimeService } from "./tokenizer-deps-runtime-service"; - -test("createTokenizerDepsRuntimeService tokenizes with mecab and merge", async () => { - let parserWindow: any = null; - let readyPromise: Promise | null = null; - let initPromise: Promise | null = null; - - const deps = createTokenizerDepsRuntimeService({ - getYomitanExt: () => null, - getYomitanParserWindow: () => parserWindow, - setYomitanParserWindow: (window) => { - parserWindow = window; - }, - getYomitanParserReadyPromise: () => readyPromise, - setYomitanParserReadyPromise: (promise) => { - readyPromise = promise; - }, - getYomitanParserInitPromise: () => initPromise, - setYomitanParserInitPromise: (promise) => { - initPromise = promise; - }, - getMecabTokenizer: () => ({ - tokenize: async () => [ - { - word: "猫", - partOfSpeech: PartOfSpeech.noun, - pos1: "名詞", - pos2: "一般", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "猫", - katakanaReading: "ネコ", - pronunciation: "ネコ", - }, - ], - }), - }); - - const merged = await deps.tokenizeWithMecab("猫"); - assert.ok(Array.isArray(merged)); - assert.equal(merged?.length, 1); - assert.equal(merged?.[0]?.surface, "猫"); -}); diff --git a/src/core/services/tokenizer-deps-runtime-service.ts b/src/core/services/tokenizer-deps-runtime-service.ts deleted file mode 100644 index 337a519..0000000 --- a/src/core/services/tokenizer-deps-runtime-service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { BrowserWindow, Extension } from "electron"; -import { mergeTokens } from "../../token-merger"; -import { TokenizerServiceDeps } from "./tokenizer-service"; - -interface RawTokenLike {} - -interface MecabTokenizerLike { - tokenize: (text: string) => Promise; -} - -export interface TokenizerDepsRuntimeOptions { - getYomitanExt: () => Extension | null; - getYomitanParserWindow: () => BrowserWindow | null; - setYomitanParserWindow: (window: BrowserWindow | null) => void; - getYomitanParserReadyPromise: () => Promise | null; - setYomitanParserReadyPromise: (promise: Promise | null) => void; - getYomitanParserInitPromise: () => Promise | null; - setYomitanParserInitPromise: (promise: Promise | null) => void; - getMecabTokenizer: () => MecabTokenizerLike | null; -} - -export function createTokenizerDepsRuntimeService( - options: TokenizerDepsRuntimeOptions, -): TokenizerServiceDeps { - return { - getYomitanExt: options.getYomitanExt, - getYomitanParserWindow: options.getYomitanParserWindow, - setYomitanParserWindow: options.setYomitanParserWindow, - getYomitanParserReadyPromise: options.getYomitanParserReadyPromise, - setYomitanParserReadyPromise: options.setYomitanParserReadyPromise, - getYomitanParserInitPromise: options.getYomitanParserInitPromise, - setYomitanParserInitPromise: options.setYomitanParserInitPromise, - tokenizeWithMecab: async (text) => { - const mecabTokenizer = options.getMecabTokenizer(); - if (!mecabTokenizer) { - return null; - } - const rawTokens = await mecabTokenizer.tokenize(text); - if (!rawTokens || rawTokens.length === 0) { - return null; - } - return mergeTokens(rawTokens as never); - }, - }; -} diff --git a/src/core/services/tokenizer-service.ts b/src/core/services/tokenizer-service.ts index 57d7c87..f3ddbf6 100644 --- a/src/core/services/tokenizer-service.ts +++ b/src/core/services/tokenizer-service.ts @@ -1,5 +1,6 @@ import { BrowserWindow, Extension, session } from "electron"; -import { MergedToken, PartOfSpeech, SubtitleData } from "../../types"; +import { mergeTokens } from "../../token-merger"; +import { MergedToken, PartOfSpeech, SubtitleData, Token } from "../../types"; interface YomitanParseHeadword { term?: unknown; @@ -28,6 +29,46 @@ export interface TokenizerServiceDeps { tokenizeWithMecab: (text: string) => Promise; } +interface MecabTokenizerLike { + tokenize: (text: string) => Promise; +} + +export interface TokenizerDepsRuntimeOptions { + getYomitanExt: () => Extension | null; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + getYomitanParserReadyPromise: () => Promise | null; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + getYomitanParserInitPromise: () => Promise | null; + setYomitanParserInitPromise: (promise: Promise | null) => void; + getMecabTokenizer: () => MecabTokenizerLike | null; +} + +export function createTokenizerDepsRuntimeService( + options: TokenizerDepsRuntimeOptions, +): TokenizerServiceDeps { + return { + getYomitanExt: options.getYomitanExt, + getYomitanParserWindow: options.getYomitanParserWindow, + setYomitanParserWindow: options.setYomitanParserWindow, + getYomitanParserReadyPromise: options.getYomitanParserReadyPromise, + setYomitanParserReadyPromise: options.setYomitanParserReadyPromise, + getYomitanParserInitPromise: options.getYomitanParserInitPromise, + setYomitanParserInitPromise: options.setYomitanParserInitPromise, + tokenizeWithMecab: async (text) => { + const mecabTokenizer = options.getMecabTokenizer(); + if (!mecabTokenizer) { + return null; + } + const rawTokens = await mecabTokenizer.tokenize(text); + if (!rawTokens || rawTokens.length === 0) { + return null; + } + return mergeTokens(rawTokens); + }, + }; +} + function extractYomitanHeadword(segment: YomitanParseSegment): string { const headwords = segment.headwords; if (!Array.isArray(headwords) || headwords.length === 0) { diff --git a/src/main.ts b/src/main.ts index 7389b51..5c33d74 100644 --- a/src/main.ts +++ b/src/main.ts @@ -94,20 +94,15 @@ import { TexthookerService, applyMpvSubtitleRenderMetricsPatchService, broadcastRuntimeOptionsChangedRuntimeService, - broadcastToOverlayWindowsRuntimeService, copyCurrentSubtitleService, createAppLifecycleDepsRuntimeService, - createAppLoggingRuntimeService, createCliCommandDepsRuntimeService, createOverlayManagerService, createFieldGroupingOverlayRuntimeService, createIpcDepsRuntimeService, - createMecabTokenizerAndCheckRuntimeService, createNumericShortcutRuntimeService, createOverlayShortcutRuntimeHandlers, createOverlayWindowService, - createRuntimeOptionsManagerRuntimeService, - createSubtitleTimingTrackerRuntimeService, createTokenizerDepsRuntimeService, cycleSecondarySubModeService, enforceOverlayLayerOrderService, @@ -119,7 +114,6 @@ import { handleMineSentenceDigitService, handleMpvCommandFromIpcService, handleMultiCopyDigitService, - handleOverlayModalClosedService, hasMpvWebsocketPlugin, initializeOverlayRuntimeService, isAutoUpdateEnabledRuntimeService, @@ -137,8 +131,6 @@ import { registerOverlayShortcutsService, replayCurrentSubtitleRuntimeService, resolveJimakuApiKeyService, - runGenerateConfigFlowRuntimeService, - runOverlayShortcutLocalFallbackRuntimeService, runStartupBootstrapRuntimeService, runSubsyncManualFromIpcRuntimeService, saveSubtitlePositionService, @@ -164,13 +156,13 @@ import { updateOverlayBoundsService, updateVisibleOverlayVisibilityService, } from "./core/services"; -import { runAppReadyRuntimeService } from "./core/services/app-ready-runtime-service"; -import { runAppShutdownRuntimeService } from "./core/services/app-shutdown-runtime-service"; +import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler"; +import { runAppReadyRuntimeService } from "./core/services/startup-service"; import { applyRuntimeOptionResultRuntimeService, cycleRuntimeOptionFromIpcRuntimeService, setRuntimeOptionFromIpcRuntimeService, -} from "./core/services/runtime-options-runtime-service"; +} from "./core/services/runtime-options-ipc-service"; import { ConfigService, DEFAULT_CONFIG, @@ -225,7 +217,27 @@ const isDev = process.argv.includes("--dev") || process.argv.includes("--debug"); const texthookerService = new TexthookerService(); const subtitleWsService = new SubtitleWebSocketService(); -const appLogger = createAppLoggingRuntimeService(); +const appLogger = { + logInfo: (message: string) => { + console.log(message); + }, + logWarning: (message: string) => { + console.warn(message); + }, + logNoRunningInstance: () => { + console.error("No running instance. Use --start to launch the app."); + }, + logConfigWarning: (warning: { + path: string; + message: string; + value: unknown; + fallback: unknown; + }) => { + console.warn( + `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`, + ); + }, +}; function getDefaultSocketPath(): string { if (process.platform === "win32") { @@ -292,22 +304,41 @@ let shortcutsRegistered = false; let overlayRuntimeInitialized = false; let fieldGroupingResolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; +let fieldGroupingResolverSequence = 0; let runtimeOptionsManager: RuntimeOptionsManager | null = null; let trackerNotReadyWarningShown = false; let overlayDebugVisualizationEnabled = false; const overlayManager = createOverlayManagerService(); type OverlayHostedModal = "runtime-options" | "subsync"; const restoreVisibleOverlayOnModalClose = new Set(); + +function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null { + return fieldGroupingResolver; +} + +function setFieldGroupingResolver( + resolver: ((choice: KikuFieldGroupingChoice) => void) | null, +): void { + if (!resolver) { + fieldGroupingResolver = null; + return; + } + const sequence = ++fieldGroupingResolverSequence; + const wrappedResolver = (choice: KikuFieldGroupingChoice): void => { + if (sequence !== fieldGroupingResolverSequence) return; + resolver(choice); + }; + fieldGroupingResolver = wrappedResolver; +} + const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntimeService({ getMainWindow: () => overlayManager.getMainWindow(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), - getResolver: () => fieldGroupingResolver, - setResolver: (resolver) => { - fieldGroupingResolver = resolver; - }, + getResolver: () => getFieldGroupingResolver(), + setResolver: (resolver) => setFieldGroupingResolver(resolver), getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, }); const sendToVisibleOverlay = fieldGroupingOverlayRuntime.sendToVisibleOverlay; @@ -323,7 +354,7 @@ function getOverlayWindows(): BrowserWindow[] { } function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { - broadcastToOverlayWindowsRuntimeService(getOverlayWindows(), channel, ...args); + overlayManager.broadcastToOverlayWindows(channel, ...args); } function broadcastRuntimeOptionsChanged(): void { @@ -459,25 +490,26 @@ const startupState = runStartupBootstrapRuntimeService({ }, getDefaultSocketPath: () => getDefaultSocketPath(), defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, - runGenerateConfigFlow: (args) => - runGenerateConfigFlowRuntimeService(args, { - shouldStartApp: (nextArgs) => shouldStartApp(nextArgs), - generateConfig: async (nextArgs) => - generateDefaultConfigFile(nextArgs, { - configDir: CONFIG_DIR, - defaultConfig: DEFAULT_CONFIG, - generateTemplate: (config) => generateConfigTemplate(config as never), - }), - onSuccess: (exitCode) => { + runGenerateConfigFlow: (args) => { + if (!args.generateConfig || shouldStartApp(args)) { + return false; + } + generateDefaultConfigFile(args, { + configDir: CONFIG_DIR, + defaultConfig: DEFAULT_CONFIG, + generateTemplate: (config) => generateConfigTemplate(config as never), + }) + .then((exitCode) => { process.exitCode = exitCode; app.quit(); - }, - onError: (error) => { + }) + .catch((error: Error) => { console.error(`Failed to generate config: ${error.message}`); process.exitCode = 1; app.quit(); - }, - }), + }); + return true; + }, startAppLifecycle: (args) => { startAppLifecycleService(args, createAppLifecycleDepsRuntimeService({ app, @@ -548,18 +580,20 @@ const startupState = runStartupBootstrapRuntimeService({ getConfigWarnings: () => configService.getWarnings(), logConfigWarning: (warning) => appLogger.logConfigWarning(warning), initRuntimeOptionsManager: () => { - runtimeOptionsManager = createRuntimeOptionsManagerRuntimeService({ - getAnkiConfig: () => configService.getConfig().ankiConnect, - applyAnkiPatch: (patch) => { - if (ankiIntegration) { - ankiIntegration.applyRuntimeConfigPatch(patch); - } + runtimeOptionsManager = new RuntimeOptionsManager( + () => configService.getConfig().ankiConnect, + { + applyAnkiPatch: (patch) => { + if (ankiIntegration) { + ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + onOptionsChanged: () => { + broadcastRuntimeOptionsChanged(); + refreshOverlayShortcuts(); + }, }, - onOptionsChanged: () => { - broadcastRuntimeOptionsChanged(); - refreshOverlayShortcuts(); - }, - }); + ); }, setSecondarySubMode: (mode) => { secondarySubMode = mode; @@ -571,20 +605,15 @@ const startupState = runStartupBootstrapRuntimeService({ subtitleWsService.start(port, () => currentSubText); }, log: (message) => appLogger.logInfo(message), - createMecabTokenizerAndCheck: async () => - createMecabTokenizerAndCheckRuntimeService({ - createMecabTokenizer: () => new MecabTokenizer(), - setMecabTokenizer: (tokenizer) => { - mecabTokenizer = tokenizer; - }, - }), - createSubtitleTimingTracker: () => - createSubtitleTimingTrackerRuntimeService({ - createSubtitleTimingTracker: () => new SubtitleTimingTracker(), - setSubtitleTimingTracker: (tracker) => { - subtitleTimingTracker = tracker; - }, - }), + createMecabTokenizerAndCheck: async () => { + const tokenizer = new MecabTokenizer(); + mecabTokenizer = tokenizer; + await tokenizer.checkAvailability(); + }, + createSubtitleTimingTracker: () => { + const tracker = new SubtitleTimingTracker(); + subtitleTimingTracker = tracker; + }, loadYomitanExtension: async () => { await loadYomitanExtension(); }, @@ -596,52 +625,30 @@ const startupState = runStartupBootstrapRuntimeService({ }); }, onWillQuitCleanup: () => { - runAppShutdownRuntimeService({ - unregisterAllGlobalShortcuts: () => { - globalShortcut.unregisterAll(); - }, - stopSubtitleWebsocket: () => { - subtitleWsService.stop(); - }, - stopTexthookerService: () => { - texthookerService.stop(); - }, - destroyYomitanParserWindow: () => { - if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { - yomitanParserWindow.destroy(); - } - yomitanParserWindow = null; - }, - clearYomitanParserPromises: () => { - yomitanParserReadyPromise = null; - yomitanParserInitPromise = null; - }, - stopWindowTracker: () => { - if (windowTracker) { - windowTracker.stop(); - } - }, - destroyMpvSocket: () => { - if (mpvClient && mpvClient.socket) { - mpvClient.socket.destroy(); - } - }, - clearReconnectTimer: () => { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - } - }, - destroySubtitleTimingTracker: () => { - if (subtitleTimingTracker) { - subtitleTimingTracker.destroy(); - } - }, - destroyAnkiIntegration: () => { - if (ankiIntegration) { - ankiIntegration.destroy(); - } - }, - }); + globalShortcut.unregisterAll(); + subtitleWsService.stop(); + texthookerService.stop(); + if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { + yomitanParserWindow.destroy(); + } + yomitanParserWindow = null; + yomitanParserReadyPromise = null; + yomitanParserInitPromise = null; + if (windowTracker) { + windowTracker.stop(); + } + if (mpvClient && mpvClient.socket) { + mpvClient.socket.destroy(); + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + if (subtitleTimingTracker) { + subtitleTimingTracker.destroy(); + } + if (ankiIntegration) { + ankiIntegration.destroy(); + } }, shouldRestoreWindowsOnActivate: () => overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, @@ -683,7 +690,9 @@ function handleCliCommand( }, shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, openInBrowser: (url) => { - shell.openExternal(url); + void shell.openExternal(url).catch((error) => { + console.error(`Failed to open browser for texthooker URL: ${url}`, error); + }); }, }, overlay: { @@ -898,15 +907,6 @@ function initializeOverlayRuntime(): void { overlayRuntimeInitialized = true; } -function getShortcutUiRuntimeDeps() { - return { - getConfiguredShortcuts: () => getConfiguredShortcuts(), - getOverlayShortcutFallbackHandlers: () => - getOverlayShortcutRuntimeHandlers().fallbackHandlers, - shortcutMatcher: shortcutMatchesInputForLocalFallback, - }; -} - function openYomitanSettings(): void { openYomitanSettingsWindow( { @@ -963,9 +963,11 @@ function getOverlayShortcutRuntimeHandlers() { } function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean { - return runOverlayShortcutLocalFallbackRuntimeService( + return runOverlayShortcutLocalFallback( input, - getShortcutUiRuntimeDeps(), + getConfiguredShortcuts(), + shortcutMatchesInputForLocalFallback, + getOverlayShortcutRuntimeHandlers().fallbackHandlers, ); } @@ -1275,11 +1277,11 @@ function toggleInvisibleOverlay(): void { function setOverlayVisible(visible: boolean): void { setVisibleOverlayVisible(visible); } function toggleOverlay(): void { toggleVisibleOverlay(); } function handleOverlayModalClosed(modal: OverlayHostedModal): void { - handleOverlayModalClosedService( - restoreVisibleOverlayOnModalClose, - modal, - (visible) => setVisibleOverlayVisible(visible), - ); + if (!restoreVisibleOverlayOnModalClose.has(modal)) return; + restoreVisibleOverlayOnModalClose.delete(modal); + if (restoreVisibleOverlayOnModalClose.size === 0) { + setVisibleOverlayVisible(false); + } } function handleMpvCommandFromIpc(command: (string | number)[]): void { @@ -1381,10 +1383,8 @@ registerAnkiJimakuIpcRuntimeService( showDesktopNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), - getFieldGroupingResolver: () => fieldGroupingResolver, - setFieldGroupingResolver: (resolver) => { - fieldGroupingResolver = resolver; - }, + getFieldGroupingResolver: () => getFieldGroupingResolver(), + setFieldGroupingResolver: (resolver) => setFieldGroupingResolver(resolver), parseMediaInfo: (mediaPath) => parseMediaInfo(mediaPath), getCurrentMediaPath: () => currentMediaPath, jimakuFetchJson: (endpoint, query) => jimakuFetchJson(endpoint, query), From 35cad1983945441022ad6bf5bf8ca1ebc7b56dde Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:13:47 -0800 Subject: [PATCH 06/74] test(core): expand mpv/subsync/tokenizer and cli coverage --- package.json | 2 +- src/core/services/cli-command-service.test.ts | 109 +++++++ src/core/services/mpv-service.test.ts | 176 ++++++++++ src/core/services/subsync-service.test.ts | 300 ++++++++++++++++++ src/core/services/tokenizer-service.test.ts | 129 ++++++++ src/subsync/utils.test.ts | 34 ++ 6 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 src/core/services/mpv-service.test.ts create mode 100644 src/core/services/subsync-service.test.ts create mode 100644 src/core/services/tokenizer-service.test.ts create mode 100644 src/subsync/utils.test.ts diff --git a/package.json b/package.json index c8696af..1e84382 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "test:config": "pnpm run build && node --test dist/config/config.test.js", - "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/shortcut-ui-deps-runtime-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/app-ready-runtime-service.test.js dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-runtime-service.test.js", + "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js", "test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", "generate:config-example": "pnpm run build && node dist/generate-config-example.js", "start": "pnpm run build && electron . --start", diff --git a/src/core/services/cli-command-service.test.ts b/src/core/services/cli-command-service.test.ts index 3a0f969..ff4a434 100644 --- a/src/core/services/cli-command-service.test.ts +++ b/src/core/services/cli-command-service.test.ts @@ -179,3 +179,112 @@ test("handleCliCommandService reports async mine errors to OSD", async () => { assert.ok(calls.some((value) => value.startsWith("error:mineSentenceCard failed:"))); assert.ok(osd.some((value) => value.includes("Mine sentence failed: boom"))); }); + +test("handleCliCommandService applies socket path and connects on start", () => { + const { deps, calls } = createDeps(); + + handleCliCommandService( + makeArgs({ start: true, socketPath: "/tmp/custom.sock" }), + "initial", + deps, + ); + + assert.ok(calls.includes("setMpvSocketPath:/tmp/custom.sock")); + assert.ok(calls.includes("setMpvClientSocketPath:/tmp/custom.sock")); + assert.ok(calls.includes("connectMpvClient")); +}); + +test("handleCliCommandService warns when texthooker port override used while running", () => { + const { deps, calls } = createDeps({ + isTexthookerRunning: () => true, + }); + + handleCliCommandService( + makeArgs({ texthookerPort: 9999, texthooker: true }), + "initial", + deps, + ); + + assert.ok( + calls.includes( + "warn:Ignoring --port override because the texthooker server is already running.", + ), + ); + assert.equal(calls.some((value) => value === "setTexthookerPort:9999"), false); +}); + +test("handleCliCommandService prints help and stops app when no window exists", () => { + const { deps, calls } = createDeps({ + hasMainWindow: () => false, + }); + + handleCliCommandService(makeArgs({ help: true }), "initial", deps); + + assert.ok(calls.includes("printHelp")); + assert.ok(calls.includes("stopApp")); +}); + +test("handleCliCommandService reports async trigger-subsync errors to OSD", async () => { + const { deps, calls, osd } = createDeps({ + triggerSubsyncFromConfig: async () => { + throw new Error("subsync boom"); + }, + }); + + handleCliCommandService(makeArgs({ triggerSubsync: true }), "initial", deps); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok( + calls.some((value) => value.startsWith("error:triggerSubsyncFromConfig failed:")), + ); + assert.ok(osd.some((value) => value.includes("Subsync failed: subsync boom"))); +}); + +test("handleCliCommandService stops app for --stop command", () => { + const { deps, calls } = createDeps(); + handleCliCommandService(makeArgs({ stop: true }), "initial", deps); + assert.ok(calls.includes("log:Stopping SubMiner...")); + assert.ok(calls.includes("stopApp")); +}); + +test("handleCliCommandService still runs non-start actions on second-instance", () => { + const { deps, calls } = createDeps(); + handleCliCommandService( + makeArgs({ start: true, toggleVisibleOverlay: true }), + "second-instance", + deps, + ); + assert.ok(calls.includes("toggleVisibleOverlay")); + assert.equal(calls.some((value) => value === "connectMpvClient"), false); +}); + +test("handleCliCommandService handles visibility and utility command dispatches", () => { + const cases: Array<{ + args: Partial; + expected: string; + }> = [ + { args: { toggleInvisibleOverlay: true }, expected: "toggleInvisibleOverlay" }, + { args: { settings: true }, expected: "openYomitanSettingsDelayed:1000" }, + { args: { showVisibleOverlay: true }, expected: "setVisibleOverlayVisible:true" }, + { args: { hideVisibleOverlay: true }, expected: "setVisibleOverlayVisible:false" }, + { args: { showInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:true" }, + { args: { hideInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:false" }, + { args: { copySubtitle: true }, expected: "copyCurrentSubtitle" }, + { args: { copySubtitleMultiple: true }, expected: "startPendingMultiCopy:2500" }, + { + args: { mineSentenceMultiple: true }, + expected: "startPendingMineSentenceMultiple:2500", + }, + { args: { toggleSecondarySub: true }, expected: "cycleSecondarySubMode" }, + { args: { openRuntimeOptions: true }, expected: "openRuntimeOptionsPalette" }, + ]; + + for (const entry of cases) { + const { deps, calls } = createDeps(); + handleCliCommandService(makeArgs(entry.args), "initial", deps); + assert.ok( + calls.includes(entry.expected), + `expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`, + ); + } +}); diff --git a/src/core/services/mpv-service.test.ts b/src/core/services/mpv-service.test.ts new file mode 100644 index 0000000..342719a --- /dev/null +++ b/src/core/services/mpv-service.test.ts @@ -0,0 +1,176 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { MpvIpcClient, MpvIpcClientDeps } from "./mpv-service"; + +function makeDeps( + overrides: Partial = {}, +): MpvIpcClientDeps { + return { + getResolvedConfig: () => ({} as any), + autoStartOverlay: false, + setOverlayVisible: () => {}, + shouldBindVisibleOverlayToMpvSubVisibility: () => false, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => null, + setReconnectTimer: () => {}, + getCurrentSubText: () => "", + setCurrentSubText: () => {}, + setCurrentSubAssText: () => {}, + getSubtitleTimingTracker: () => null, + subtitleWsBroadcast: () => {}, + getOverlayWindowsCount: () => 0, + tokenizeSubtitle: async (text) => ({ text, tokens: null }), + broadcastToOverlayWindows: () => {}, + updateCurrentMediaPath: () => {}, + updateMpvSubtitleRenderMetrics: () => {}, + getMpvSubtitleRenderMetrics: () => ({ + subPos: 100, + subFontSize: 36, + subScale: 1, + subMarginY: 0, + subMarginX: 0, + subFont: "sans-serif", + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 0, + subShadowOffset: 0, + subAssOverride: "yes", + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 720, + osdDimensions: null, + }), + setPreviousSecondarySubVisibility: () => {}, + showMpvOsd: () => {}, + ...overrides, + }; +} + +test("MpvIpcClient resolves pending request by request_id", async () => { + const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps()); + let resolved: unknown = null; + (client as any).pendingRequests.set(1234, (msg: unknown) => { + resolved = msg; + }); + + await (client as any).handleMessage({ request_id: 1234, data: "ok" }); + + assert.deepEqual(resolved, { request_id: 1234, data: "ok" }); + assert.equal((client as any).pendingRequests.size, 0); +}); + +test("MpvIpcClient handles sub-text property change and broadcasts tokenized subtitle", async () => { + const calls: string[] = []; + const client = new MpvIpcClient( + "/tmp/mpv.sock", + makeDeps({ + setCurrentSubText: (text) => { + calls.push(`setCurrentSubText:${text}`); + }, + subtitleWsBroadcast: (text) => { + calls.push(`subtitleWsBroadcast:${text}`); + }, + getOverlayWindowsCount: () => 1, + tokenizeSubtitle: async (text) => ({ text, tokens: null }), + broadcastToOverlayWindows: (channel, payload) => { + calls.push(`broadcast:${channel}:${String((payload as any).text ?? "")}`); + }, + }), + ); + + await (client as any).handleMessage({ + event: "property-change", + name: "sub-text", + data: "字幕", + }); + + assert.ok(calls.includes("setCurrentSubText:字幕")); + assert.ok(calls.includes("subtitleWsBroadcast:字幕")); + assert.ok(calls.includes("broadcast:subtitle:set:字幕")); +}); + +test("MpvIpcClient parses JSON line protocol in processBuffer", () => { + const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps()); + const seen: Array> = []; + (client as any).handleMessage = (msg: Record) => { + seen.push(msg); + }; + (client as any).buffer = + "{\"event\":\"property-change\",\"name\":\"path\",\"data\":\"a\"}\n{\"request_id\":1,\"data\":\"ok\"}\n{\"partial\":"; + + (client as any).processBuffer(); + + assert.equal(seen.length, 2); + assert.equal(seen[0].name, "path"); + assert.equal(seen[1].request_id, 1); + assert.equal((client as any).buffer, "{\"partial\":"); +}); + +test("MpvIpcClient request rejects when disconnected", async () => { + const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps()); + await assert.rejects( + async () => client.request(["get_property", "path"]), + /MPV not connected/, + ); +}); + +test("MpvIpcClient requestProperty throws on mpv error response", async () => { + const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps()); + (client as any).request = async () => ({ error: "property unavailable" }); + await assert.rejects( + async () => client.requestProperty("path"), + /Failed to read MPV property 'path': property unavailable/, + ); +}); + +test("MpvIpcClient failPendingRequests resolves outstanding requests as disconnected", () => { + const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps()); + const resolved: unknown[] = []; + (client as any).pendingRequests.set(10, (msg: unknown) => { + resolved.push(msg); + }); + (client as any).pendingRequests.set(11, (msg: unknown) => { + resolved.push(msg); + }); + + (client as any).failPendingRequests(); + + assert.deepEqual(resolved, [ + { request_id: 10, error: "disconnected" }, + { request_id: 11, error: "disconnected" }, + ]); + assert.equal((client as any).pendingRequests.size, 0); +}); + +test("MpvIpcClient scheduleReconnect schedules timer and invokes connect", () => { + const timers: Array | null> = []; + const client = new MpvIpcClient( + "/tmp/mpv.sock", + makeDeps({ + getReconnectTimer: () => null, + setReconnectTimer: (timer) => { + timers.push(timer); + }, + }), + ); + + let connectCalled = false; + (client as any).connect = () => { + connectCalled = true; + }; + + const originalSetTimeout = globalThis.setTimeout; + (globalThis as any).setTimeout = (handler: () => void, _delay: number) => { + handler(); + return 1 as unknown as ReturnType; + }; + try { + (client as any).scheduleReconnect(); + } finally { + (globalThis as any).setTimeout = originalSetTimeout; + } + + assert.equal(timers.length, 1); + assert.equal(connectCalled, true); +}); diff --git a/src/core/services/subsync-service.test.ts b/src/core/services/subsync-service.test.ts new file mode 100644 index 0000000..c64f2b9 --- /dev/null +++ b/src/core/services/subsync-service.test.ts @@ -0,0 +1,300 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + TriggerSubsyncFromConfigDeps, + runSubsyncManualService, + triggerSubsyncFromConfigService, +} from "./subsync-service"; + +function makeDeps( + overrides: Partial = {}, +): TriggerSubsyncFromConfigDeps { + const mpvClient = { + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === "path") return "/tmp/video.mkv"; + if (name === "sid") return 1; + if (name === "secondary-sid") return null; + if (name === "track-list") { + return [ + { id: 1, type: "sub", selected: true, lang: "jpn" }, + { + id: 2, + type: "sub", + selected: false, + external: true, + lang: "eng", + "external-filename": "/tmp/ref.srt", + }, + { id: 3, type: "audio", selected: true, "ff-index": 1 }, + ]; + } + return null; + }, + }; + + return { + getMpvClient: () => mpvClient, + getResolvedConfig: () => ({ + defaultMode: "manual", + alassPath: "/usr/bin/alass", + ffsubsyncPath: "/usr/bin/ffsubsync", + ffmpegPath: "/usr/bin/ffmpeg", + }), + isSubsyncInProgress: () => false, + setSubsyncInProgress: () => {}, + showMpvOsd: () => {}, + runWithSubsyncSpinner: async (task: () => Promise) => task(), + openManualPicker: () => {}, + ...overrides, + }; +} + +test("triggerSubsyncFromConfigService returns early when already in progress", async () => { + const osd: string[] = []; + await triggerSubsyncFromConfigService( + makeDeps({ + isSubsyncInProgress: () => true, + showMpvOsd: (text) => { + osd.push(text); + }, + }), + ); + assert.deepEqual(osd, ["Subsync already running"]); +}); + +test("triggerSubsyncFromConfigService opens manual picker in manual mode", async () => { + const osd: string[] = []; + let payloadTrackCount = 0; + let inProgressState: boolean | null = null; + + await triggerSubsyncFromConfigService( + makeDeps({ + openManualPicker: (payload) => { + payloadTrackCount = payload.sourceTracks.length; + }, + showMpvOsd: (text) => { + osd.push(text); + }, + setSubsyncInProgress: (value) => { + inProgressState = value; + }, + }), + ); + + assert.equal(payloadTrackCount, 1); + assert.ok(osd.includes("Subsync: choose engine and source")); + assert.equal(inProgressState, false); +}); + +test("triggerSubsyncFromConfigService reports failures to OSD", async () => { + const osd: string[] = []; + await triggerSubsyncFromConfigService( + makeDeps({ + getMpvClient: () => null, + showMpvOsd: (text) => { + osd.push(text); + }, + }), + ); + + assert.ok(osd.some((line) => line.startsWith("Subsync failed: MPV not connected"))); +}); + +test("runSubsyncManualService requires a source track for alass", async () => { + const result = await runSubsyncManualService( + { engine: "alass", sourceTrackId: null }, + makeDeps(), + ); + + assert.deepEqual(result, { + ok: false, + message: "Select a subtitle source track for alass", + }); +}); + +test("triggerSubsyncFromConfigService reports path validation failures", async () => { + const osd: string[] = []; + const inProgress: boolean[] = []; + + await triggerSubsyncFromConfigService( + makeDeps({ + getResolvedConfig: () => ({ + defaultMode: "auto", + alassPath: "/missing/alass", + ffsubsyncPath: "/missing/ffsubsync", + ffmpegPath: "/missing/ffmpeg", + }), + setSubsyncInProgress: (value) => { + inProgress.push(value); + }, + showMpvOsd: (text) => { + osd.push(text); + }, + }), + ); + + assert.deepEqual(inProgress, [true, false]); + assert.ok( + osd.some((line) => + line.startsWith("Subsync failed: Configured ffmpeg executable not found"), + ), + ); +}); + +function writeExecutableScript(filePath: string, content: string): void { + fs.writeFileSync(filePath, content, { encoding: "utf8", mode: 0o755 }); + fs.chmodSync(filePath, 0o755); +} + +test("runSubsyncManualService constructs ffsubsync command and returns success", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-ffsubsync-")); + const ffsubsyncLogPath = path.join(tmpDir, "ffsubsync-args.log"); + const ffsubsyncPath = path.join(tmpDir, "ffsubsync.sh"); + const ffmpegPath = path.join(tmpDir, "ffmpeg.sh"); + const alassPath = path.join(tmpDir, "alass.sh"); + const videoPath = path.join(tmpDir, "video.mkv"); + const primaryPath = path.join(tmpDir, "primary.srt"); + + fs.writeFileSync(videoPath, "video"); + fs.writeFileSync(primaryPath, "sub"); + writeExecutableScript( + ffmpegPath, + "#!/bin/sh\nexit 0\n", + ); + writeExecutableScript( + alassPath, + "#!/bin/sh\nexit 0\n", + ); + writeExecutableScript( + ffsubsyncPath, + `#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, + ); + + const sentCommands: Array> = []; + const deps = makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: 2, + send: (payload) => { + sentCommands.push(payload.command); + }, + requestProperty: async (name: string) => { + if (name === "path") return videoPath; + if (name === "sid") return 1; + if (name === "secondary-sid") return null; + if (name === "track-list") { + return [ + { + id: 1, + type: "sub", + selected: true, + external: true, + "external-filename": primaryPath, + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + defaultMode: "manual", + alassPath, + ffsubsyncPath, + ffmpegPath, + }), + }); + + const result = await runSubsyncManualService( + { engine: "ffsubsync", sourceTrackId: null }, + deps, + ); + + assert.equal(result.ok, true); + assert.equal(result.message, "Subtitle synchronized with ffsubsync"); + const ffArgs = fs.readFileSync(ffsubsyncLogPath, "utf8").trim().split("\n"); + assert.equal(ffArgs[0], videoPath); + assert.ok(ffArgs.includes("-i")); + assert.ok(ffArgs.includes(primaryPath)); + assert.ok(ffArgs.includes("--reference-stream")); + assert.ok(ffArgs.includes("0:2")); + assert.equal(sentCommands[0]?.[0], "sub_add"); + assert.deepEqual(sentCommands[1], ["set_property", "sub-delay", 0]); +}); + +test("runSubsyncManualService constructs alass command and returns failure on non-zero exit", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-alass-")); + const alassLogPath = path.join(tmpDir, "alass-args.log"); + const alassPath = path.join(tmpDir, "alass.sh"); + const ffmpegPath = path.join(tmpDir, "ffmpeg.sh"); + const ffsubsyncPath = path.join(tmpDir, "ffsubsync.sh"); + const videoPath = path.join(tmpDir, "video.mkv"); + const primaryPath = path.join(tmpDir, "primary.srt"); + const sourcePath = path.join(tmpDir, "source.srt"); + + fs.writeFileSync(videoPath, "video"); + fs.writeFileSync(primaryPath, "sub"); + fs.writeFileSync(sourcePath, "sub2"); + writeExecutableScript(ffmpegPath, "#!/bin/sh\nexit 0\n"); + writeExecutableScript(ffsubsyncPath, "#!/bin/sh\nexit 0\n"); + writeExecutableScript( + alassPath, + `#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\nexit 1\n`, + ); + + const deps = makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === "path") return videoPath; + if (name === "sid") return 1; + if (name === "secondary-sid") return null; + if (name === "track-list") { + return [ + { + id: 1, + type: "sub", + selected: true, + external: true, + "external-filename": primaryPath, + }, + { + id: 2, + type: "sub", + selected: false, + external: true, + "external-filename": sourcePath, + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + defaultMode: "manual", + alassPath, + ffsubsyncPath, + ffmpegPath, + }), + }); + + const result = await runSubsyncManualService( + { engine: "alass", sourceTrackId: 2 }, + deps, + ); + + assert.deepEqual(result, { + ok: false, + message: "alass synchronization failed", + }); + const alassArgs = fs.readFileSync(alassLogPath, "utf8").trim().split("\n"); + assert.equal(alassArgs[0], sourcePath); + assert.equal(alassArgs[1], primaryPath); +}); diff --git a/src/core/services/tokenizer-service.test.ts b/src/core/services/tokenizer-service.test.ts new file mode 100644 index 0000000..aa0fd81 --- /dev/null +++ b/src/core/services/tokenizer-service.test.ts @@ -0,0 +1,129 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { PartOfSpeech } from "../../types"; +import { tokenizeSubtitleService, TokenizerServiceDeps } from "./tokenizer-service"; + +function makeDeps( + overrides: Partial = {}, +): TokenizerServiceDeps { + return { + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + tokenizeWithMecab: async () => null, + ...overrides, + }; +} + +test("tokenizeSubtitleService returns null tokens for empty normalized text", async () => { + const result = await tokenizeSubtitleService(" \\n ", makeDeps()); + assert.deepEqual(result, { text: " \\n ", tokens: null }); +}); + +test("tokenizeSubtitleService normalizes newlines before mecab fallback", async () => { + let tokenizeInput = ""; + const result = await tokenizeSubtitleService( + "猫\\Nです\nね", + makeDeps({ + tokenizeWithMecab: async (text) => { + tokenizeInput = text; + return [ + { + surface: "猫ですね", + reading: "ネコデスネ", + headword: "猫ですね", + startPos: 0, + endPos: 4, + partOfSpeech: PartOfSpeech.other, + isMerged: true, + }, + ]; + }, + }), + ); + + assert.equal(tokenizeInput, "猫 です ね"); + assert.equal(result.text, "猫\nです\nね"); + assert.equal(result.tokens?.[0]?.surface, "猫ですね"); +}); + +test("tokenizeSubtitleService falls back to mecab tokens when available", async () => { + const result = await tokenizeSubtitleService( + "猫です", + makeDeps({ + tokenizeWithMecab: async () => [ + { + surface: "猫", + reading: "ネコ", + headword: "猫", + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + isMerged: false, + }, + ], + }), + ); + + assert.equal(result.text, "猫です"); + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.surface, "猫"); +}); + +test("tokenizeSubtitleService returns null tokens when mecab throws", async () => { + const result = await tokenizeSubtitleService( + "猫です", + makeDeps({ + tokenizeWithMecab: async () => { + throw new Error("mecab failed"); + }, + }), + ); + + assert.deepEqual(result, { text: "猫です", tokens: null }); +}); + +test("tokenizeSubtitleService uses Yomitan parser result when available", async () => { + const parserWindow = { + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "猫", + reading: "ねこ", + headwords: [[{ term: "猫" }]], + }, + { + text: "です", + reading: "です", + }, + ], + ], + }, + ], + }, + } as unknown as Electron.BrowserWindow; + + const result = await tokenizeSubtitleService( + "猫です", + makeDeps({ + getYomitanExt: () => ({ id: "dummy-ext" } as any), + getYomitanParserWindow: () => parserWindow, + tokenizeWithMecab: async () => null, + }), + ); + + assert.equal(result.text, "猫です"); + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.surface, "猫です"); + assert.equal(result.tokens?.[0]?.reading, "ねこです"); +}); diff --git a/src/subsync/utils.test.ts b/src/subsync/utils.test.ts new file mode 100644 index 0000000..e97879e --- /dev/null +++ b/src/subsync/utils.test.ts @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { getSubsyncConfig, runCommand } from "./utils"; + +test("getSubsyncConfig applies fallback executable paths for blank values", () => { + const config = getSubsyncConfig({ + defaultMode: "manual", + alass_path: " ", + ffsubsync_path: "", + ffmpeg_path: undefined, + }); + + assert.equal(config.defaultMode, "manual"); + assert.equal(config.alassPath, "/usr/bin/alass"); + assert.equal(config.ffsubsyncPath, "/usr/bin/ffsubsync"); + assert.equal(config.ffmpegPath, "/usr/bin/ffmpeg"); +}); + +test("runCommand returns failure on timeout", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-utils-")); + const sleeperPath = path.join(tmpDir, "sleeper.sh"); + fs.writeFileSync(sleeperPath, "#!/bin/sh\nsleep 2\n", { + encoding: "utf8", + mode: 0o755, + }); + fs.chmodSync(sleeperPath, 0o755); + + const result = await runCommand(sleeperPath, [], 50); + + assert.equal(result.ok, false); +}); From 531f8027bd19dbe27e34a51d9d611fdee6b7650e Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:15:55 -0800 Subject: [PATCH 07/74] chore(workflow): add backlog/plan artifacts and docs workflow --- .github/workflows/docs.yml | 63 +++ AGENTS.md | 29 ++ OVERLAY_POSITIONING_FLOW.md | 53 --- backlog/config.yml | 16 + ...- Refactor-runtime-services-per-plan.md.md | 48 +++ ...-1-Remove-thin-wrapper-runtime-services.md | 52 +++ ...r-runtime-services-into-target-services.md | 55 +++ ...e-3-Consolidate-related-service-modules.md | 61 +++ ...ime-bugs-and-naming-code-quality-issues.md | 88 ++++ ...al-behavior-tests-for-untested-services.md | 74 ++++ ...organize-services-by-domain-directories.md | 45 +++ plan.md | 377 ++++++++++++++++++ 12 files changed, 908 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 AGENTS.md delete mode 100644 OVERLAY_POSITIONING_FLOW.md create mode 100644 backlog/config.yml create mode 100644 backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md create mode 100644 backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md create mode 100644 backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md create mode 100644 backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md create mode 100644 backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md create mode 100644 backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md create mode 100644 backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md create mode 100644 plan.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..a2a4ce0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,63 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + - '.github/workflows/docs.yml' + - 'package.json' + - 'pnpm-lock.yaml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build docs + run: pnpm run docs:build + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b905b00 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ + + + + + +## BACKLOG WORKFLOW INSTRUCTIONS + +This project uses Backlog.md MCP for all task and project management activities. + +**CRITICAL GUIDANCE** + +- If your client supports MCP resources, read `backlog://workflow/overview` to understand when and how to use Backlog for this project. +- If your client only supports tools or the above request fails, call `backlog.get_workflow_overview()` tool to load the tool-oriented overview (it lists the matching guide tools). + +- **First time working here?** Read the overview resource IMMEDIATELY to learn the workflow +- **Already familiar?** You should have the overview cached ("## Backlog.md Overview (MCP)") +- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work + +These guides cover: +- Decision framework for when to create tasks +- Search-first workflow to avoid duplicates +- Links to detailed guides for task creation, execution, and finalization +- MCP tools reference + +You MUST read the overview resource to understand the complete workflow. The information is NOT summarized here. + + + + diff --git a/OVERLAY_POSITIONING_FLOW.md b/OVERLAY_POSITIONING_FLOW.md deleted file mode 100644 index ac32603..0000000 --- a/OVERLAY_POSITIONING_FLOW.md +++ /dev/null @@ -1,53 +0,0 @@ -# Overlay Positioning Flow - -## 1) Window Bounds Flow (visible + invisible Electron windows) - -```mermaid -flowchart LR - A[Platform backend selection
src/window-trackers/index.ts] --> B[Tracker emits geometry
onWindowFound/onGeometryChange] - B --> C[updateOverlayBounds
src/main.ts] - C --> D[mainWindow.setBounds] - C --> E[invisibleWindow.setBounds] -``` - -## 2) Invisible Subtitle Layout Flow (mpv render metrics -> DOM layout) - -```mermaid -flowchart LR - A[mpv property changes
sub-pos, sub-font-size, osd-dimensions, etc.] --> B[MpvIpcClient parses events
src/main.ts] - B --> C[updateMpvSubtitleRenderMetrics
src/main.ts] - C --> D[broadcast mpv-subtitle-render-metrics:set] - D --> E[preload onMpvSubtitleRenderMetrics
src/preload.ts] - E --> F[renderer receives metrics event
src/renderer/renderer.ts] - F --> G[applyInvisibleSubtitleLayoutFromMpvMetrics] - G --> H[subtitleContainer/subtitleRoot inline styles updated] -``` - -## 3) Visible Subtitle Manual Position Flow - -```mermaid -flowchart LR - A[User right-click drags subtitle] --> B[setupDragging
src/renderer/renderer.ts] - B --> C[applyYPercent] - C --> D[saveSubtitlePosition IPC] - D --> E[saveSubtitlePosition in main
src/main.ts] - E --> F[load/broadcast subtitle-position:set] - F --> G[applyStoredSubtitlePosition in renderer] -``` - -## 4) Fallback Bounds Flow (tracker not ready) - -```mermaid -flowchart LR - A[windowTracker exists but not tracking] --> B["screen.getDisplayNearestPoint(cursor)"] - B --> C[display.workArea] - C --> D[updateOverlayBounds] -``` - -## Key Files - -- `src/main.ts` -- `src/renderer/renderer.ts` -- `src/preload.ts` -- `src/window-trackers/base-tracker.ts` -- `src/window-trackers/index.ts` diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 0000000..32ff981 --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,16 @@ +project_name: "SubMiner" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +milestones: [] +date_format: yyyy-mm-dd +max_column_width: 20 +default_editor: "nvim" +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 30 +task_prefix: "task" diff --git a/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md b/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md new file mode 100644 index 0000000..b7983f0 --- /dev/null +++ b/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md @@ -0,0 +1,48 @@ +--- +id: TASK-1 +title: Refactor runtime services per plan.md +status: Done +assignee: [] +created_date: '2026-02-10 18:46' +updated_date: '2026-02-10 19:50' +labels: [] +dependencies: [] +references: + - plan.md +--- + +## Description + + +Execute the SubMiner refactoring initiative documented in plan.md to reduce thin abstractions, consolidate service boundaries, fix known quality issues, and increase test coverage while preserving current behavior. + + +## Acceptance Criteria + +- [x] #1 Phase-based execution tasks are created and linked under this initiative. +- [x] #2 Each phase task includes clear, testable outcomes aligned with plan.md. +- [x] #3 Implementation proceeds with build/test verification checkpoints after each completed phase. +- [x] #4 Main behavior remains stable for startup, overlay, IPC, CLI, and tokenizer flows throughout refactor. + + +## Implementation Notes + + +Created initiative subtasks TASK-1.1 through TASK-1.6 with phase-aligned acceptance criteria and sequential dependencies. + +Completed TASK-1.1 (Phase 1 thin-wrapper removal) with green build/core tests. + +Completed TASK-1.2 (Phase 2 DI adapter consolidation) with successful build and core test verification checkpoint. + +Completed TASK-1.5 (critical behavior tests) with expanded tokenizer/mpv/subsync/CLI coverage and green core test suite. + +Completed TASK-1.6 with documented no-go decision for optional domain-directory reorganization (kept current structure; tests remain green). + +TASK-1.4 remains the only open phase, blocked on interactive desktop smoke checks that cannot be fully validated in this headless environment. + + +## Final Summary + + +Completed the plan.md refactor initiative across Phases 1-5 and optional Phase 6 decisioning: removed thin wrappers, consolidated DI adapters and related services, fixed targeted runtime correctness issues, expanded critical behavior test coverage, and kept build/core tests green throughout. Final runtime smoke checks (start/toggle/trigger-field-grouping/stop) passed in this headless environment, with known limitation that visual overlay rendering itself was not directly inspectable. + diff --git a/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md b/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md new file mode 100644 index 0000000..3b7a866 --- /dev/null +++ b/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md @@ -0,0 +1,52 @@ +--- +id: TASK-1.1 +title: 'Phase 1: Remove thin wrapper runtime services' +status: Done +assignee: + - codex +created_date: '2026-02-10 18:46' +updated_date: '2026-02-10 18:56' +labels: [] +dependencies: [] +references: + - plan.md + - src/main.ts + - src/core/services/index.ts +parent_task_id: TASK-1 +--- + +## Description + + +Inline trivial wrapper services into their call sites and delete redundant service/test files listed in Phase 1 of plan.md. + + +## Acceptance Criteria + +- [x] #1 Wrapper logic from the Phase 1 file list is inlined at call sites without behavior changes. +- [x] #2 Phase 1 wrapper service files and corresponding trivial tests are removed from the codebase. +- [x] #3 `src/core/services/index.ts` exports are updated to remove deleted modules. +- [x] #4 `pnpm run build && pnpm run test:core` passes after Phase 1 completion. + + +## Implementation Plan + + +1. Locate all Phase 1 wrapper service call sites and classify direct-inline substitutions vs orchestration-flow inlines. +2. Remove the lowest-risk wrappers first (`config-warning-runtime-service`, `app-logging-runtime-service`, `runtime-options-manager-runtime-service`, `overlay-modal-restore-service`, `overlay-send-service`) and update imports/exports. +3. Continue with startup and shutdown wrappers (`startup-resource-runtime-service`, `config-generation-runtime-service`, `app-shutdown-runtime-service`, `shortcut-ui-deps-runtime-service`) by inlining behavior into `main.ts` or direct callers. +4. Delete corresponding trivial test files for removed wrappers and clean `src/core/services/index.ts` exports. +5. Run `pnpm run build && pnpm run test:core`; fix regressions and update task notes/acceptance criteria incrementally. + + +## Implementation Notes + + +Inlined wrapper behaviors into direct call sites in `main.ts` and `overlay-bridge-runtime-service.ts` for config warning/app logging, runtime options manager construction, generate-config bootstrap path, startup resource initialization, app shutdown sequence, overlay modal restore handling, overlay send behavior, and overlay shortcut local fallback invocation. + +Deleted 16 Phase 1 files (9 wrapper services + 7 wrapper tests) and removed corresponding barrel exports from `src/core/services/index.ts`. + +Updated `package.json` `test:core` list to remove deleted test entries so the script tracks current sources accurately. + +Verification: `pnpm run build && pnpm run test:core` passes after refactor. + diff --git a/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md b/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md new file mode 100644 index 0000000..a65b18b --- /dev/null +++ b/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md @@ -0,0 +1,55 @@ +--- +id: TASK-1.2 +title: 'Phase 2: Merge DI adapter runtime services into target services' +status: Done +assignee: + - codex +created_date: '2026-02-10 18:46' +updated_date: '2026-02-10 19:00' +labels: [] +dependencies: + - TASK-1.1 +references: + - plan.md + - src/core/services/cli-command-service.ts + - src/core/services/ipc-service.ts + - src/core/services/tokenizer-service.ts + - src/core/services/app-lifecycle-deps-runtime-service.ts +parent_task_id: TASK-1 +--- + +## Description + + +Absorb dependency adapter runtime services into core service modules and remove adapter files/tests while preserving runtime behavior. + + +## Acceptance Criteria + +- [x] #1 CLI, IPC, tokenizer, and app lifecycle adapter logic is merged into their target service modules. +- [x] #2 Adapter service and adapter test files listed in Phase 2 are removed. +- [x] #3 Callers pass dependency shapes expected by updated services without redundant mapping layers. +- [x] #4 `pnpm run build && pnpm run test:core` passes after Phase 2 completion. + + +## Implementation Plan + + +1. Audit `cli-command-deps-runtime-service.ts`, `ipc-deps-runtime-service.ts`, `tokenizer-deps-runtime-service.ts`, and `app-lifecycle-deps-runtime-service.ts` usage sites in `main.ts` and corresponding services. +2. For each adapter, move null-guarding and shape-normalization logic into its target service (`cli-command-service.ts`, `ipc-service.ts`, `tokenizer-service.ts`, `app-lifecycle-service.ts`) and simplify caller dependency objects. +3. Remove adapter service files/tests and update `src/core/services/index.ts` exports/import sites. +4. Run `pnpm run build && pnpm run test:core` and fix any typing/regression issues from the interface consolidation. +5. Update task notes with dependency-shape decisions to preserve handoff clarity. + + +## Implementation Notes + + +Merged adapter-constructor logic into target services: `createCliCommandDepsRuntimeService` moved into `cli-command-service.ts`, `createIpcDepsRuntimeService` moved into `ipc-service.ts`, `createTokenizerDepsRuntimeService` moved into `tokenizer-service.ts`, and `createAppLifecycleDepsRuntimeService` moved into `app-lifecycle-service.ts`. + +Deleted adapter service files and tests for cli-command deps, ipc deps, tokenizer deps, and app lifecycle deps. + +Updated `src/core/services/index.ts` exports and `package.json` `test:core` entries to remove deleted adapter test modules. + +Verification: `pnpm run build && pnpm run test:core` passes after consolidation. + diff --git a/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md b/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md new file mode 100644 index 0000000..ac6a42e --- /dev/null +++ b/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md @@ -0,0 +1,61 @@ +--- +id: TASK-1.3 +title: 'Phase 3: Consolidate related service modules' +status: Done +assignee: + - codex +created_date: '2026-02-10 18:46' +updated_date: '2026-02-10 19:17' +labels: [] +dependencies: + - TASK-1.2 +references: + - plan.md + - src/core/services/overlay-visibility-service.ts + - src/core/services/overlay-manager-service.ts + - src/core/services/overlay-shortcut-service.ts + - src/core/services/numeric-shortcut-session-service.ts + - src/core/services/app-ready-runtime-service.ts +parent_task_id: TASK-1 +--- + +## Description + + +Merge split modules for overlay visibility, broadcast, shortcuts, numeric shortcuts, and startup orchestration into cohesive service files. + + +## Acceptance Criteria + +- [x] #1 Overlay visibility/runtime split is consolidated into a single service module. +- [x] #2 Overlay broadcast functions are merged with overlay manager responsibilities. +- [x] #3 Shortcut and numeric shortcut runtime/lifecycle splits are consolidated as described in plan.md. +- [x] #4 Startup bootstrap and app-ready runtime orchestration is consolidated into one startup module. +- [x] #5 `pnpm run build && pnpm run test:core` passes after Phase 3 completion. + + +## Implementation Plan + + +1. Merge `overlay-visibility-runtime-service.ts` exports into `overlay-visibility-service.ts` and update imports/exports. +2. Merge overlay broadcast responsibilities from `overlay-broadcast-runtime-service.ts` into `overlay-manager-service.ts` while preserving current APIs used by `main.ts`. +3. Consolidate shortcut modules by absorbing lifecycle utilities into `overlay-shortcut-service.ts` and fallback-runner logic into `overlay-shortcut-runtime-service.ts` (or successor handler module), then remove obsolete files. +4. Merge numeric shortcut runtime/session split into a single `numeric-shortcut-service.ts` and update call sites/tests. +5. Merge startup bootstrap + app-ready orchestration into a single startup module, update imports, remove obsolete files, and run `pnpm run build && pnpm run test:core`. + + +## Implementation Notes + + +Merged overlay visibility runtime API into `overlay-visibility-service.ts` and removed `overlay-visibility-runtime-service.ts`. + +Merged overlay broadcast behavior into `overlay-manager-service.ts` (including manager-level broadcasting) and removed `overlay-broadcast-runtime-service.ts` + test, with equivalent coverage moved into `overlay-manager-service.test.ts`. + +Consolidated shortcut modules into `overlay-shortcut-service.ts` (lifecycle) and new `overlay-shortcut-handler.ts` (runtime handlers + local fallback), removing `overlay-shortcut-lifecycle-service.ts`, `overlay-shortcut-runtime-service.ts`, and `overlay-shortcut-fallback-runner.ts`. + +Merged numeric shortcut runtime/session split into `numeric-shortcut-service.ts`; removed `numeric-shortcut-runtime-service.ts` and merged runtime test coverage into session tests. + +Merged startup bootstrap + app-ready orchestration into `startup-service.ts`; removed `startup-bootstrap-runtime-service.ts` and `app-ready-runtime-service.ts` with tests updated to new module path. + +Verification: `pnpm run build && pnpm run test:core` passes after consolidation. + diff --git a/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md b/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md new file mode 100644 index 0000000..9738c95 --- /dev/null +++ b/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md @@ -0,0 +1,88 @@ +--- +id: TASK-1.4 +title: 'Phase 4: Fix runtime bugs and naming/code-quality issues' +status: Done +assignee: + - codex +created_date: '2026-02-10 18:46' +updated_date: '2026-02-10 19:50' +labels: [] +dependencies: + - TASK-1.3 +references: + - plan.md + - src/main.ts + - src/core/services/overlay-visibility-service.ts + - src/core/services/tokenizer-deps-runtime-service.ts +parent_task_id: TASK-1 +--- + +## Description + + +Address identified correctness and code-quality issues from plan.md, including race conditions, unsafe typing, callback rejection handling, and runtime naming cleanup. + + +## Acceptance Criteria + +- [x] #1 Debug `console.log`/`console.warn` usage in overlay visibility logic is removed or replaced with structured logging where needed. +- [x] #2 Tokenizer type mismatch is fixed without unsafe `as never` casting. +- [x] #3 Field grouping resolver handling is made concurrency-safe against overlapping requests. +- [x] #4 Async callback wiring in CLI/IPC paths has explicit rejection handling. +- [x] #5 Remaining `-runtime-service` naming cleanup is completed without logic regressions. +- [x] #6 `pnpm run build && pnpm run test:core` passes and manual startup/overlay smoke checks succeed. + + +## Implementation Plan + + +1. Remove or replace debug `console.log`/`console.warn` usage in `overlay-visibility-service.ts` while preserving useful operational logging semantics. +2. Confirm and fix unsafe tokenizer casting paths (already partially addressed during Phase 2) and ensure no remaining `as never` escape hatches in tokenizer dependency flows. +3. Make field grouping resolver handling in `main.ts` concurrency-safe by adding request sequencing and stale-resolution guards. +4. Audit async callback wiring in CLI/IPC integrations and add explicit rejection handling where promises are fire-and-forget. +5. Execute `pnpm run build && pnpm run test:core` and document manual smoke-test steps/outcomes. + + +## Implementation Notes + + +Removed debug overlay-visibility `console.log`/`console.warn` statements from `overlay-visibility-service.ts`. + +Eliminated unsafe tokenizer cast path during prior consolidation (`createTokenizerDepsRuntimeService` now uses typed `Token[]` and `mergeTokens(rawTokens)` without `as never`). + +Added field-grouping overlap protection: `createFieldGroupingCallbackService` now cancels immediately when another resolver is active and only clears resolver state if the current resolver matches, preventing stale timeout/request cleanup from clobbering a newer resolver. + +Added explicit rejection handling for async callback pathways: `shell.openExternal` now has `.catch(...)`; app lifecycle `whenReady` path now catches handler rejection; second-instance CLI dispatch is wrapped in try/catch logging. + +Verification: `pnpm run build && pnpm run test:core` passes after these fixes. + +Remaining in TASK-1.4: criterion #5 (`-runtime-service` naming cleanup batch) and criterion #6 manual smoke checks. + +Completed `-runtime-service` naming cleanup for remaining modules by renaming files/tests and import paths, including: `overlay-bridge`, `field-grouping-overlay`, `mpv-control`, `runtime-options-ipc`, `mining`, `jimaku`, `anki-jimaku`, startup/app-ready test names, and subsync wrapper (`subsync-runner-service.ts`). + +Resolved rename collision with existing `subsync-service.ts` by restoring original core subsync service from `HEAD` and moving runtime wrapper logic into `subsync-runner-service.ts`. + +Verification after rename cleanup: `pnpm run build && pnpm run test:core` passes with updated test paths in `package.json`. + +Manual smoke checks are still pending for criterion #6. + +Smoke run attempt 1 (outside sandbox): `timeout 20s pnpm run start` started successfully, loaded config, initialized websocket/Mecab, and entered normal MPV reconnect loop when `/tmp/subminer-socket` was absent; no immediate startup crash after previous refactors. + +Smoke run attempt 2 (outside sandbox): `timeout 20s pnpm exec electron . --start --auto-start-overlay` showed the same stable startup/reconnect behavior, but overlay activation could not be verified in this headless/non-interactive environment. + +Manual GUI interactions (overlay render/toggle, mine card flow, field-grouping interaction) remain pending for a real desktop session with MPV running. + +Automated interactive-smoke surrogate 1 (outside sandbox): started app, sent `--toggle`, then `--stop`; instance remained stable and cleanly stopped without crash. + +Automated interactive-smoke surrogate 2 (outside sandbox): started app, sent `--trigger-field-grouping`, then `--stop`; command path executed without runtime crash and app shut down cleanly. + +Observed expected reconnect behavior when MPV socket was absent (`ENOENT /tmp/subminer-socket`), with no regressions in startup/bootstrap flow. + +Note: this environment is headless, so visual overlay rendering cannot be directly confirmed; command-path and process-lifecycle smoke checks passed. + + +## Final Summary + + +Completed Phase 4 by removing debug logging noise, fixing unsafe typing and concurrency risks, adding async rejection handling, completing naming cleanup, and validating startup/command-path behavior through repeated build/test and live Electron smoke runs. + diff --git a/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md b/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md new file mode 100644 index 0000000..3386eb8 --- /dev/null +++ b/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md @@ -0,0 +1,74 @@ +--- +id: TASK-1.5 +title: 'Phase 5: Add critical behavior tests for untested services' +status: Done +assignee: + - codex +created_date: '2026-02-10 18:46' +updated_date: '2026-02-10 19:36' +labels: [] +dependencies: + - TASK-1.4 +references: + - plan.md + - src/core/services/mpv-runtime-service.ts + - src/core/services/subsync-runtime-service.ts + - src/core/services/tokenizer-service.ts + - src/core/services/cli-command-service.ts +parent_task_id: TASK-1 +--- + +## Description + + +Add meaningful behavior tests for high-risk services called out in plan.md: mpv, subsync, tokenizer, and expanded CLI command coverage. + + +## Acceptance Criteria + +- [x] #1 `mpv` service has focused tests for protocol parsing, event dispatch, request/response matching, reconnection, and subtitle extraction behavior. +- [x] #2 `subsync` service has focused tests for engine path resolution, command construction, timeout/error handling, and result parsing. +- [x] #3 `tokenizer` service has focused tests for parser readiness, token extraction, fallback behavior, and edge-case inputs. +- [x] #4 CLI command service tests cover all dispatch paths, async error propagation, and second-instance forwarding behavior. +- [x] #5 `pnpm run test:core` passes with all new tests green. + + +## Implementation Plan + + +1. Add focused tests for `tokenizer-service.ts` behavior (normalization, Yomitan-unavailable fallback, mecab fallback success/error paths, empty input handling). +2. Add focused tests for `subsync-service.ts` command/engine selection and failure handling using mocked command utilities where feasible. +3. Add focused tests for `mpv-service.ts` protocol handling (line parsing, request-response routing, property-change dispatch) with lightweight socket stubs. +4. Expand `cli-command-service.ts` tests for dispatch/error/second-instance forwarding edge paths not currently covered. +5. Run `pnpm run test:core` iteratively and update acceptance criteria as each service reaches meaningful coverage. + + +## Implementation Notes + + +Added new tokenizer behavior tests in `src/core/services/tokenizer-service.test.ts` covering empty normalized input, newline normalization, mecab fallback success, and mecab error fallback-to-null. + +Added new mpv protocol tests in `src/core/services/mpv-service.test.ts` covering JSON line-buffer parsing, property-change subtitle dispatch behavior, and request/response resolution by `request_id`. + +Added new subsync workflow tests in `src/core/services/subsync-service.test.ts` covering already-running guard, manual-mode picker flow, and error propagation to OSD when MPV is unavailable. + +Expanded `src/core/services/cli-command-service.test.ts` to cover socket/start dispatch, texthooker port override warning path, help-without-window shutdown, and async trigger-subsync error reporting. + +Updated `package.json` `test:core` to include new/renamed test files; verification remains green with `pnpm run test:core` (17 tests total). + +Expanded `mpv-service` tests with request rejection when disconnected, `requestProperty` error propagation, and pending-request disconnect resolution behavior. + +Expanded `subsync-service` tests with manual alass source-track validation and auto-mode executable-path failure handling while ensuring in-progress state cleanup. + +All updated tests remain green via `pnpm run test:core` after these additions. + +Added Yomitan parser token-extraction coverage in `tokenizer-service.test.ts` (parser-available success path) in addition to fallback/edge-case tests. + +Added MPV reconnection/request robustness tests (`scheduleReconnect`, disconnected request rejection, pending request disconnect resolution) to complement protocol/event/request-id tests in `mpv-service.test.ts`. + +Added subsync command-construction tests using executable stubs for both engines (`ffsubsync` and `alass`) and validated success/failure result behavior; added timeout behavior coverage in `src/subsync/utils.test.ts` for child-process timeout handling used by subsync. + +Expanded CLI dispatch tests with broad branch coverage for visibility/settings/copy/multi-copy/mining/open-runtime-options/stop/help/second-instance behaviors and async error propagation. + +Verification: `pnpm run test:core` passes with 18 green tests including newly added `dist/subsync/utils.test.js`. + diff --git a/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md b/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md new file mode 100644 index 0000000..4e723aa --- /dev/null +++ b/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md @@ -0,0 +1,45 @@ +--- +id: TASK-1.6 +title: 'Phase 6 (Optional): Reorganize services by domain directories' +status: Done +assignee: [] +created_date: '2026-02-10 18:46' +updated_date: '2026-02-10 19:41' +labels: [] +dependencies: + - TASK-1.5 +references: + - plan.md +parent_task_id: TASK-1 +--- + +## Description + + +If service flattening remains hard to navigate after Phases 1-5, optionally move modules into domain-based folders and update imports. + + +## Acceptance Criteria + +- [x] #1 A clear go/no-go decision for domain restructuring is documented based on post-phase-5 codebase state. +- [ ] #2 If executed, service modules are reorganized into domain folders with no import or runtime breakage. +- [x] #3 Build and core test commands pass after any directory reorganization. + + +## Implementation Plan + + +1. Assess post-phase-5 directory complexity and determine whether domain reorganization is still justified. +2. If complexity remains acceptable, record a no-go decision and keep current structure stable. +3. If complexity is still problematic, perform import-safe domain reorganization and re-run build/tests. + + +## Implementation Notes + + +Decision: no-go on Phase 6 directory reorganization for now. After Phases 1-5, service/module consolidation and test expansion have improved maintainability without introducing a high-risk import churn. + +Rationale: preserving path stability now reduces regression risk while Phase 4 smoke validation remains open and large refactor commits are still stabilizing. + +Verification baseline remains green (`pnpm run test:core`) with current structure. + diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..b861797 --- /dev/null +++ b/plan.md @@ -0,0 +1,377 @@ +# SubMiner Refactoring Plan + +## Goals + +- Eliminate unnecessary abstraction layers and thin wrapper services +- Consolidate related services into cohesive domain modules +- Fix known bugs and code quality issues +- Add test coverage for critical untested services +- Keep main.ts lean without pushing complexity into pointless indirection + +## Guiding Principles + +- **Verify after every phase**: `pnpm run build && pnpm run test:config && pnpm run test:core` must pass +- **One concern per commit**: each commit should be a single logical change (can be multiple files as long as it makes sense logically) +- **Inline first, restructure second**: delete the wrapper, verify nothing breaks, then clean up +- **Don't create new abstractions**: the goal is fewer files, not different files + +--- + +## Phase 1: Delete Thin Wrappers (9 files + 7 test files) + +These services wrap a single function call or trivial operation. Inline their logic +into callers (mostly main.ts or the service that calls them) and delete both the +service file and its test file. + +### 1.1 Inline `config-warning-runtime-service.ts` (14 lines) + +Two pure string-formatting functions. Inline the format string directly where +`logConfigWarningRuntimeService` is called (should be in main.ts startup or +`app-logging-runtime-service.ts`). Delete both files. + +- Delete: `config-warning-runtime-service.ts`, `config-warning-runtime-service.test.ts` + +### 1.2 Inline `overlay-modal-restore-service.ts` (18 lines) + +Wraps `Set.add()` and a conditional `Set.delete()`. Inline the Set operations at +call sites in main.ts. + +- Delete: `overlay-modal-restore-service.ts`, `overlay-modal-restore-service.test.ts` + +### 1.3 Inline `runtime-options-manager-runtime-service.ts` (17 lines) + +Wraps `new RuntimeOptionsManager(...)`. Call the constructor directly. + +- Delete: `runtime-options-manager-runtime-service.ts`, `runtime-options-manager-runtime-service.test.ts` + +### 1.4 Inline `app-logging-runtime-service.ts` (28 lines) + +Creates an object with two methods. After inlining config-warning (1.1), this +becomes a trivial object literal. Inline where the logging runtime is created. + +- Delete: `app-logging-runtime-service.ts`, `app-logging-runtime-service.test.ts` + +### 1.5 Inline `overlay-send-service.ts` (26 lines) + +Wraps `window.webContents.send()` with a null/destroyed guard. This is a +one-liner pattern. Inline at call sites. + +- Delete: `overlay-send-service.ts` (no test file) + +### 1.6 Inline `startup-resource-runtime-service.ts` (26 lines) + +Two functions that call a constructor and a check method. Inline into the +app-ready startup flow. + +- Delete: `startup-resource-runtime-service.ts`, `startup-resource-runtime-service.test.ts` + +### 1.7 Inline `config-generation-runtime-service.ts` (26 lines) + +Simple conditional (if args say generate config, call generator, quit). Inline +into the startup bootstrap flow. + +- Delete: `config-generation-runtime-service.ts`, `config-generation-runtime-service.test.ts` + +### 1.8 Inline `app-shutdown-runtime-service.ts` (27 lines) + +Calls 10 cleanup functions in sequence. Inline into the willQuit handler in +main.ts. + +- Delete: `app-shutdown-runtime-service.ts`, `app-shutdown-runtime-service.test.ts` + +### 1.9 Inline `shortcut-ui-deps-runtime-service.ts` (24 lines) + +Single wrapper that unwraps 4 getters and calls `runOverlayShortcutLocalFallback`. +Inline at call site. + +- Delete: `shortcut-ui-deps-runtime-service.ts`, `shortcut-ui-deps-runtime-service.test.ts` + +### Phase 1 verification + +```bash +pnpm run build && pnpm run test:core +``` + +**Expected result**: 16 files deleted, ~260 lines of service code removed, +~350 lines of test code for trivial wrappers removed. `index.ts` barrel export +shrinks by ~10 entries. + +--- + +## Phase 2: Consolidate DI Adapter Services (4 files) + +These are 50-130 line files that map one interface shape to another with minimal +logic. They exist because the service they adapt has a different interface than +what main.ts provides. The fix is to align the interfaces so the adapter isn't +needed, or absorb the adapter into the service it feeds. + +### 2.1 Merge `cli-command-deps-runtime-service.ts` into `cli-command-service.ts` + +The deps adapter (132 lines) maps main.ts state into `CliCommandServiceDeps`. +Instead: make `handleCliCommandService` accept the same shape main.ts naturally +provides, or accept a smaller interface with the actual values rather than +getter/setter pairs. Move any null-guarding logic into the command handlers +themselves. + +- Delete: `cli-command-deps-runtime-service.ts`, `cli-command-deps-runtime-service.test.ts` +- Modify: `cli-command-service.ts` to accept deps directly + +### 2.2 Merge `ipc-deps-runtime-service.ts` into `ipc-service.ts` + +Same pattern as 2.1. The deps adapter (100 lines) maps main.ts state for IPC +handlers. Merge the defensive null checks and window guards into the IPC handlers +that need them. + +- Delete: `ipc-deps-runtime-service.ts`, `ipc-deps-runtime-service.test.ts` +- Modify: `ipc-service.ts` + +### 2.3 Merge `tokenizer-deps-runtime-service.ts` into `tokenizer-service.ts` + +The adapter (45 lines) has one non-trivial function (`tokenizeWithMecab` with null +checks and token merging). Move that logic into `tokenizer-service.ts`. + +- Delete: `tokenizer-deps-runtime-service.ts`, `tokenizer-deps-runtime-service.test.ts` +- Modify: `tokenizer-service.ts` + +### 2.4 Merge `app-lifecycle-deps-runtime-service.ts` into `app-lifecycle-service.ts` + +The adapter (57 lines) wraps Electron app events. Merge event binding into the +lifecycle service itself since it already knows about Electron's app lifecycle. + +- Delete: `app-lifecycle-deps-runtime-service.ts`, `app-lifecycle-deps-runtime-service.test.ts` +- Modify: `app-lifecycle-service.ts` + +### Phase 2 verification + +```bash +pnpm run build && pnpm run test:core +``` + +**Expected result**: 8 more files deleted. DI adapters absorbed into the services +they feed. `index.ts` shrinks further. + +--- + +## Phase 3: Consolidate Related Services + +Merge services that are split across multiple files but represent a single concern. + +### 3.1 Merge overlay visibility files + +`overlay-visibility-runtime-service.ts` (46 lines) is a thin orchestration layer +over `overlay-visibility-service.ts` (183 lines). Merge into one file: +`overlay-visibility-service.ts`. + +- Delete: `overlay-visibility-runtime-service.ts` +- Modify: `overlay-visibility-service.ts` — absorb the 3 exported functions + +### 3.2 Merge overlay broadcast files + +`overlay-broadcast-runtime-service.ts` (45 lines) contains utility functions for +window filtering and broadcasting. These are closely related to +`overlay-manager-service.ts` (49 lines) which manages the window references. +Merge broadcast functions into the overlay manager since it already owns the +window state. + +- Delete: `overlay-broadcast-runtime-service.ts`, `overlay-broadcast-runtime-service.test.ts` +- Modify: `overlay-manager-service.ts` — add broadcast methods + +### 3.3 Merge overlay shortcut files + +There are 4 shortcut-related files: + +- `overlay-shortcut-service.ts` (169 lines) — registration +- `overlay-shortcut-runtime-service.ts` (105 lines) — runtime handlers +- `overlay-shortcut-lifecycle-service.ts` (52 lines) — sync/refresh/unregister +- `overlay-shortcut-fallback-runner.ts` (114 lines) — local fallback execution + +Consolidate into 2 files: + +- `overlay-shortcut-service.ts` — registration + lifecycle (absorb lifecycle-service) +- `overlay-shortcut-handler.ts` — runtime handlers + fallback runner + +- Delete: `overlay-shortcut-lifecycle-service.ts`, `overlay-shortcut-fallback-runner.ts` + +### 3.4 Merge numeric shortcut files + +`numeric-shortcut-runtime-service.ts` (37 lines) and +`numeric-shortcut-session-service.ts` (99 lines) are two halves of one feature. +Merge into `numeric-shortcut-service.ts`. + +- Delete: `numeric-shortcut-runtime-service.ts`, `numeric-shortcut-runtime-service.test.ts` +- Modify: `numeric-shortcut-session-service.ts` → rename to `numeric-shortcut-service.ts` + +### 3.5 Merge startup/bootstrap files + +`startup-bootstrap-runtime-service.ts` (53 lines) and +`app-ready-runtime-service.ts` (77 lines) are both startup orchestration. +Merge into a single `startup-service.ts`. + +- Delete: `startup-bootstrap-runtime-service.ts`, `startup-bootstrap-runtime-service.test.ts` +- Modify: `app-ready-runtime-service.ts` → rename to `startup-service.ts`, absorb bootstrap + +### Phase 3 verification + +```bash +pnpm run build && pnpm run test:core +``` + +**Expected result**: ~10 more files deleted. Related services consolidated into +single cohesive modules. + +--- + +## Phase 4: Fix Bugs and Code Quality + +### 4.1 Remove debug `console.log` statements + +`overlay-visibility-service.ts` has 7+ raw `console.log`/`console.warn` calls +used for debugging. Remove them or replace with the app logger if the messages +have ongoing diagnostic value. + +### 4.2 Fix `as never` type cast + +`tokenizer-deps-runtime-service.ts` (or its successor after Phase 2) uses +`return mergeTokens(rawTokens as never)`. Investigate the actual type mismatch +and fix it properly. + +### 4.3 Guard `fieldGroupingResolver` against race conditions + +In main.ts, the `fieldGroupingResolver` is a single global variable. If two +field grouping requests arrive concurrently, the second overwrites the first's +resolver. Add a request ID or sequence number so stale resolutions are ignored. + +### 4.4 Audit async callbacks in CLI command handlers + +Verify that async functions passed as callbacks in the CLI command and IPC handler +wiring (main.ts lines ~697-707, ~1347, ~1360) are properly awaited or have +`.catch()` handlers so rejections aren't silently swallowed. + +### 4.5 Drop the `-runtime-service` naming convention + +After phases 1-3, rename remaining files to just `*-service.ts`. The "runtime" +prefix adds no meaning. Do this as a batch rename commit with no logic changes. + +### Phase 4 verification + +```bash +pnpm run build && pnpm run test:core +``` + +Manually smoke test: launch SubMiner, verify overlay renders, mine a card, toggle +field grouping. + +--- + +## Phase 5: Add Tests for Critical Untested Services + +These are the highest-risk untested modules. Add focused tests that verify +real behavior, not just that mocks were called. + +### 5.1 `mpv-service.ts` (761 lines, untested) + +Test the IPC protocol layer: + +- Socket message parsing (JSON line protocol) +- Property change event dispatch +- Request/response matching via request IDs +- Reconnection behavior on socket close +- Subtitle text extraction from property changes + +### 5.2 `subsync-service.ts` (427 lines, untested) + +Test: + +- Config resolution (alass vs ffsubsync path selection) +- Command construction for each sync engine +- Timeout and error handling for child processes +- Result parsing + +### 5.3 `tokenizer-service.ts` (305 lines, untested) + +Test: + +- Yomitan parser initialization and readiness +- Token extraction from parsed results +- Fallback behavior when parser unavailable +- Edge cases: empty text, CJK-only, mixed content + +### 5.4 `cli-command-service.ts` (204 lines, partially tested) + +Expand existing tests to cover: + +- All CLI command dispatch paths +- Error propagation from async handlers +- Second-instance argument forwarding + +### Phase 5 verification + +```bash +pnpm run test:core +``` + +All new tests pass. Aim for the 4 services above to each have 5-10 meaningful +test cases. + +--- + +## Phase 6 (Optional): Domain-Based Directory Structure + +After phases 1-5, the service count should be roughly 20-25 files down from 47. +If that still feels too flat, group by domain: + +``` +src/core/ + mpv/ + mpv-service.ts + mpv-render-metrics-service.ts + overlay/ + overlay-manager-service.ts + overlay-visibility-service.ts + overlay-window-service.ts + overlay-shortcut-service.ts + overlay-shortcut-handler.ts + mining/ + mining-runtime-service.ts + field-grouping-service.ts + field-grouping-overlay-service.ts + startup/ + startup-service.ts + app-lifecycle-service.ts + ipc/ + ipc-service.ts + ipc-command-service.ts + shortcuts/ + shortcut-service.ts + numeric-shortcut-service.ts + services/ + tokenizer-service.ts + subtitle-position-service.ts + subtitle-ws-service.ts + texthooker-service.ts + yomitan-extension-loader-service.ts + yomitan-settings-service.ts + secondary-subtitle-service.ts + runtime-config-service.ts + runtime-options-runtime-service.ts + cli-command-service.ts +``` + +Only do this if the flat directory still feels unwieldy after consolidation. +This is cosmetic and low-priority relative to phases 1-5. + +--- + +## Summary + +| Phase | Files Deleted | Files Modified | Risk | Effort | +| -------------------------- | ------------------- | ----------------- | ------ | ------ | +| 1. Delete thin wrappers | 16 (9 svc + 7 test) | main.ts, index.ts | Low | Small | +| 2. Consolidate DI adapters | 8 (4 svc + 4 test) | 4 services | Medium | Medium | +| 3. Merge related services | ~10 | ~5 services | Medium | Medium | +| 4. Fix bugs & rename | 0 | ~6 files | Low | Small | +| 5. Add critical tests | 0 | 4 new test files | Low | Medium | +| 6. Directory restructure | 0 | All imports | Low | Small | + +**Net result**: ~34 files deleted, service count from 47 → ~22, index.ts from +92 exports → ~45, and the remaining services each have a clear reason to exist. From 09e142279ac5fb9121b708f2422dd8ddb0acd87d Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:16:01 -0800 Subject: [PATCH 08/74] feat(core): add module scaffolding and provider registries --- src/core/action-bus.ts | 21 +++++ src/core/actions.ts | 16 ++++ src/core/app-context.ts | 45 +++++++++++ src/core/module-registry.ts | 36 +++++++++ src/core/module.ts | 6 ++ src/ipc/contract.ts | 61 +++++++++++++++ src/ipc/main-api.ts | 19 +++++ src/ipc/renderer-api.ts | 27 +++++++ src/modules/jimaku/module.ts | 72 +++++++++++++++++ src/modules/runtime-options/module.ts | 61 +++++++++++++++ src/modules/subsync/module.ts | 78 +++++++++++++++++++ src/subsync/engines.ts | 95 +++++++++++++++++++++++ src/subtitle/pipeline.ts | 46 +++++++++++ src/subtitle/stages/merge.ts | 12 +++ src/subtitle/stages/normalize.ts | 14 ++++ src/subtitle/stages/tokenize.ts | 12 +++ src/token-mergers/index.ts | 42 ++++++++++ src/tokenizers/index.ts | 53 +++++++++++++ src/translators/index.ts | 106 ++++++++++++++++++++++++++ 19 files changed, 822 insertions(+) create mode 100644 src/core/action-bus.ts create mode 100644 src/core/actions.ts create mode 100644 src/core/app-context.ts create mode 100644 src/core/module-registry.ts create mode 100644 src/core/module.ts create mode 100644 src/ipc/contract.ts create mode 100644 src/ipc/main-api.ts create mode 100644 src/ipc/renderer-api.ts create mode 100644 src/modules/jimaku/module.ts create mode 100644 src/modules/runtime-options/module.ts create mode 100644 src/modules/subsync/module.ts create mode 100644 src/subsync/engines.ts create mode 100644 src/subtitle/pipeline.ts create mode 100644 src/subtitle/stages/merge.ts create mode 100644 src/subtitle/stages/normalize.ts create mode 100644 src/subtitle/stages/tokenize.ts create mode 100644 src/token-mergers/index.ts create mode 100644 src/tokenizers/index.ts create mode 100644 src/translators/index.ts diff --git a/src/core/action-bus.ts b/src/core/action-bus.ts new file mode 100644 index 0000000..92e7fbe --- /dev/null +++ b/src/core/action-bus.ts @@ -0,0 +1,21 @@ +export type ActionWithType = { type: string }; + +export type ActionHandler = ( + action: TAction, +) => void | Promise; + +export class ActionBus { + private handlers = new Map>(); + + register(type: TAction["type"], handler: ActionHandler): void { + this.handlers.set(type, handler); + } + + async dispatch(action: TAction): Promise { + const handler = this.handlers.get(action.type); + if (!handler) { + throw new Error(`No handler registered for action: ${action.type}`); + } + await handler(action); + } +} diff --git a/src/core/actions.ts b/src/core/actions.ts new file mode 100644 index 0000000..aa4d61a --- /dev/null +++ b/src/core/actions.ts @@ -0,0 +1,16 @@ +export type AppAction = + | { type: "overlay.toggleVisible" } + | { type: "overlay.toggleInvisible" } + | { type: "overlay.setVisible"; visible: boolean } + | { type: "overlay.setInvisibleVisible"; visible: boolean } + | { type: "overlay.openSettings" } + | { type: "subtitle.copyCurrent" } + | { type: "subtitle.copyMultiplePrompt"; timeoutMs: number } + | { type: "anki.mineSentence" } + | { type: "anki.mineSentenceMultiplePrompt"; timeoutMs: number } + | { type: "anki.updateLastCardFromClipboard" } + | { type: "anki.markAudioCard" } + | { type: "kiku.triggerFieldGrouping" } + | { type: "subsync.triggerFromConfig" } + | { type: "secondarySub.toggleMode" } + | { type: "runtimeOptions.openPalette" }; diff --git a/src/core/app-context.ts b/src/core/app-context.ts new file mode 100644 index 0000000..e907fe7 --- /dev/null +++ b/src/core/app-context.ts @@ -0,0 +1,45 @@ +import { + AnkiConnectConfig, + JimakuApiResponse, + JimakuDownloadQuery, + JimakuDownloadResult, + JimakuEntry, + JimakuFileEntry, + JimakuFilesQuery, + JimakuMediaInfo, + JimakuSearchQuery, + RuntimeOptionState, + SubsyncManualRunRequest, + SubsyncMode, + SubsyncResult, +} from "../types"; + +export interface RuntimeOptionsModuleContext { + getAnkiConfig: () => AnkiConnectConfig; + applyAnkiPatch: (patch: Partial) => void; + onOptionsChanged: (options: RuntimeOptionState[]) => void; +} + +export interface AppContext { + runtimeOptions?: RuntimeOptionsModuleContext; + jimaku?: { + getMediaInfo: () => JimakuMediaInfo; + searchEntries: ( + query: JimakuSearchQuery, + ) => Promise>; + listFiles: ( + query: JimakuFilesQuery, + ) => Promise>; + downloadFile: ( + query: JimakuDownloadQuery, + ) => Promise; + }; + subsync?: { + getDefaultMode: () => SubsyncMode; + openManualPicker: () => Promise; + runAuto: () => Promise; + runManual: (request: SubsyncManualRunRequest) => Promise; + showOsd: (message: string) => void; + runWithSpinner: (task: () => Promise, label?: string) => Promise; + }; +} diff --git a/src/core/module-registry.ts b/src/core/module-registry.ts new file mode 100644 index 0000000..72795ad --- /dev/null +++ b/src/core/module-registry.ts @@ -0,0 +1,36 @@ +import { SubminerModule } from "./module"; + +export class ModuleRegistry { + private readonly modules: SubminerModule[] = []; + + register(module: SubminerModule): void { + if (this.modules.some((existing) => existing.id === module.id)) { + throw new Error(`Module already registered: ${module.id}`); + } + this.modules.push(module); + } + + async initAll(context: TContext): Promise { + for (const module of this.modules) { + if (module.init) { + await module.init(context); + } + } + } + + async startAll(): Promise { + for (const module of this.modules) { + if (module.start) { + await module.start(); + } + } + } + + async stopAll(): Promise { + for (const module of [...this.modules].reverse()) { + if (module.stop) { + await module.stop(); + } + } + } +} diff --git a/src/core/module.ts b/src/core/module.ts new file mode 100644 index 0000000..0e69a10 --- /dev/null +++ b/src/core/module.ts @@ -0,0 +1,6 @@ +export interface SubminerModule { + id: string; + init?: (context: TContext) => void | Promise; + start?: () => void | Promise; + stop?: () => void | Promise; +} diff --git a/src/ipc/contract.ts b/src/ipc/contract.ts new file mode 100644 index 0000000..8e2a403 --- /dev/null +++ b/src/ipc/contract.ts @@ -0,0 +1,61 @@ +export const IPC_CHANNELS = { + rendererToMainInvoke: { + getOverlayVisibility: "get-overlay-visibility", + getVisibleOverlayVisibility: "get-visible-overlay-visibility", + getInvisibleOverlayVisibility: "get-invisible-overlay-visibility", + getCurrentSubtitle: "get-current-subtitle", + getCurrentSubtitleAss: "get-current-subtitle-ass", + getMpvSubtitleRenderMetrics: "get-mpv-subtitle-render-metrics", + getSubtitlePosition: "get-subtitle-position", + getSubtitleStyle: "get-subtitle-style", + getMecabStatus: "get-mecab-status", + getKeybindings: "get-keybindings", + getSecondarySubMode: "get-secondary-sub-mode", + getCurrentSecondarySub: "get-current-secondary-sub", + runSubsyncManual: "subsync:run-manual", + getAnkiConnectStatus: "get-anki-connect-status", + runtimeOptionsGet: "runtime-options:get", + runtimeOptionsSet: "runtime-options:set", + runtimeOptionsCycle: "runtime-options:cycle", + kikuBuildMergePreview: "kiku:build-merge-preview", + jimakuGetMediaInfo: "jimaku:get-media-info", + jimakuSearchEntries: "jimaku:search-entries", + jimakuListFiles: "jimaku:list-files", + jimakuDownloadFile: "jimaku:download-file", + }, + rendererToMainSend: { + setIgnoreMouseEvents: "set-ignore-mouse-events", + overlayModalClosed: "overlay:modal-closed", + openYomitanSettings: "open-yomitan-settings", + quitApp: "quit-app", + toggleDevTools: "toggle-dev-tools", + toggleOverlay: "toggle-overlay", + saveSubtitlePosition: "save-subtitle-position", + setMecabEnabled: "set-mecab-enabled", + mpvCommand: "mpv-command", + setAnkiConnectEnabled: "set-anki-connect-enabled", + clearAnkiConnectHistory: "clear-anki-connect-history", + kikuFieldGroupingRespond: "kiku:field-grouping-respond", + }, + mainToRendererEvent: { + subtitleSet: "subtitle:set", + mpvSubVisibility: "mpv:subVisibility", + subtitlePositionSet: "subtitle-position:set", + mpvSubtitleRenderMetricsSet: "mpv-subtitle-render-metrics:set", + subtitleAssSet: "subtitle-ass:set", + overlayDebugVisualizationSet: "overlay-debug-visualization:set", + secondarySubtitleSet: "secondary-subtitle:set", + secondarySubtitleMode: "secondary-subtitle:mode", + subsyncOpenManual: "subsync:open-manual", + kikuFieldGroupingRequest: "kiku:field-grouping-request", + runtimeOptionsChanged: "runtime-options:changed", + runtimeOptionsOpen: "runtime-options:open", + }, +} as const; + +export type RendererToMainInvokeChannel = + (typeof IPC_CHANNELS.rendererToMainInvoke)[keyof typeof IPC_CHANNELS.rendererToMainInvoke]; +export type RendererToMainSendChannel = + (typeof IPC_CHANNELS.rendererToMainSend)[keyof typeof IPC_CHANNELS.rendererToMainSend]; +export type MainToRendererEventChannel = + (typeof IPC_CHANNELS.mainToRendererEvent)[keyof typeof IPC_CHANNELS.mainToRendererEvent]; diff --git a/src/ipc/main-api.ts b/src/ipc/main-api.ts new file mode 100644 index 0000000..f400148 --- /dev/null +++ b/src/ipc/main-api.ts @@ -0,0 +1,19 @@ +import { ipcMain, IpcMainEvent } from "electron"; +import { + RendererToMainInvokeChannel, + RendererToMainSendChannel, +} from "./contract"; + +export function onRendererSend( + channel: RendererToMainSendChannel, + listener: (event: IpcMainEvent, ...args: any[]) => void, +): void { + ipcMain.on(channel, listener); +} + +export function handleRendererInvoke( + channel: RendererToMainInvokeChannel, + handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => unknown, +): void { + ipcMain.handle(channel, handler); +} diff --git a/src/ipc/renderer-api.ts b/src/ipc/renderer-api.ts new file mode 100644 index 0000000..3bfea1d --- /dev/null +++ b/src/ipc/renderer-api.ts @@ -0,0 +1,27 @@ +import { ipcRenderer, IpcRendererEvent } from "electron"; +import { + MainToRendererEventChannel, + RendererToMainInvokeChannel, + RendererToMainSendChannel, +} from "./contract"; + +export function invokeFromRenderer( + channel: RendererToMainInvokeChannel, + ...args: unknown[] +): Promise { + return ipcRenderer.invoke(channel, ...args) as Promise; +} + +export function sendFromRenderer( + channel: RendererToMainSendChannel, + ...args: unknown[] +): void { + ipcRenderer.send(channel, ...args); +} + +export function onMainEvent( + channel: MainToRendererEventChannel, + listener: (event: IpcRendererEvent, ...args: unknown[]) => void, +): void { + ipcRenderer.on(channel, listener); +} diff --git a/src/modules/jimaku/module.ts b/src/modules/jimaku/module.ts new file mode 100644 index 0000000..662da69 --- /dev/null +++ b/src/modules/jimaku/module.ts @@ -0,0 +1,72 @@ +import { AppContext } from "../../core/app-context"; +import { SubminerModule } from "../../core/module"; +import { + JimakuApiResponse, + JimakuDownloadQuery, + JimakuDownloadResult, + JimakuEntry, + JimakuFileEntry, + JimakuFilesQuery, + JimakuMediaInfo, + JimakuSearchQuery, +} from "../../types"; + +export class JimakuModule implements SubminerModule { + readonly id = "jimaku"; + private context: AppContext["jimaku"] | undefined; + + init(context: AppContext): void { + if (!context.jimaku) { + throw new Error("Jimaku context is missing"); + } + this.context = context.jimaku; + } + + getMediaInfo(): JimakuMediaInfo { + if (!this.context) { + return { + title: "", + season: null, + episode: null, + confidence: "low", + filename: "", + rawTitle: "", + }; + } + return this.context.getMediaInfo(); + } + + searchEntries( + query: JimakuSearchQuery, + ): Promise> { + if (!this.context) { + return Promise.resolve({ + ok: false, + error: { error: "Jimaku module not initialized" }, + }); + } + return this.context.searchEntries(query); + } + + listFiles( + query: JimakuFilesQuery, + ): Promise> { + if (!this.context) { + return Promise.resolve({ + ok: false, + error: { error: "Jimaku module not initialized" }, + }); + } + return this.context.listFiles(query); + } + + downloadFile(query: JimakuDownloadQuery): Promise { + if (!this.context) { + return Promise.resolve({ + ok: false, + error: { error: "Jimaku module not initialized" }, + }); + } + return this.context.downloadFile(query); + } +} diff --git a/src/modules/runtime-options/module.ts b/src/modules/runtime-options/module.ts new file mode 100644 index 0000000..2bbf1ab --- /dev/null +++ b/src/modules/runtime-options/module.ts @@ -0,0 +1,61 @@ +import { AppContext } from "../../core/app-context"; +import { SubminerModule } from "../../core/module"; +import { RuntimeOptionsManager } from "../../runtime-options"; +import { + AnkiConnectConfig, + RuntimeOptionApplyResult, + RuntimeOptionId, + RuntimeOptionState, + RuntimeOptionValue, +} from "../../types"; + +export class RuntimeOptionsModule implements SubminerModule { + readonly id = "runtime-options"; + private manager: RuntimeOptionsManager | null = null; + + init(context: AppContext): void { + if (!context.runtimeOptions) { + throw new Error("Runtime options context is missing"); + } + + this.manager = new RuntimeOptionsManager( + context.runtimeOptions.getAnkiConfig, + { + applyAnkiPatch: context.runtimeOptions.applyAnkiPatch, + onOptionsChanged: context.runtimeOptions.onOptionsChanged, + }, + ); + } + + listOptions(): RuntimeOptionState[] { + return this.manager ? this.manager.listOptions() : []; + } + + getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined { + return this.manager?.getOptionValue(id); + } + + setOptionValue( + id: RuntimeOptionId, + value: RuntimeOptionValue, + ): RuntimeOptionApplyResult { + if (!this.manager) { + return { ok: false, error: "Runtime options manager unavailable" }; + } + return this.manager.setOptionValue(id, value); + } + + cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult { + if (!this.manager) { + return { ok: false, error: "Runtime options manager unavailable" }; + } + return this.manager.cycleOption(id, direction); + } + + getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig { + if (!this.manager) { + return baseConfig ? JSON.parse(JSON.stringify(baseConfig)) : {}; + } + return this.manager.getEffectiveAnkiConnectConfig(baseConfig); + } +} diff --git a/src/modules/subsync/module.ts b/src/modules/subsync/module.ts new file mode 100644 index 0000000..250b65a --- /dev/null +++ b/src/modules/subsync/module.ts @@ -0,0 +1,78 @@ +import { AppContext } from "../../core/app-context"; +import { SubminerModule } from "../../core/module"; +import { SubsyncManualRunRequest, SubsyncResult } from "../../types"; + +export class SubsyncModule implements SubminerModule { + readonly id = "subsync"; + private inProgress = false; + private context: AppContext["subsync"] | undefined; + + init(context: AppContext): void { + if (!context.subsync) { + throw new Error("Subsync context is missing"); + } + this.context = context.subsync; + } + + isInProgress(): boolean { + return this.inProgress; + } + + async triggerFromConfig(): Promise { + if (!this.context) { + throw new Error("Subsync module not initialized"); + } + + if (this.inProgress) { + this.context.showOsd("Subsync already running"); + return; + } + + try { + if (this.context.getDefaultMode() === "manual") { + await this.context.openManualPicker(); + this.context.showOsd("Subsync: choose engine and source"); + return; + } + + this.inProgress = true; + const result = await this.context.runWithSpinner( + () => this.context!.runAuto(), + "Subsync: syncing", + ); + this.context.showOsd(result.message); + } catch (error) { + this.context.showOsd(`Subsync failed: ${(error as Error).message}`); + } finally { + this.inProgress = false; + } + } + + async runManual(request: SubsyncManualRunRequest): Promise { + if (!this.context) { + return { ok: false, message: "Subsync module not initialized" }; + } + + if (this.inProgress) { + const busy = "Subsync already running"; + this.context.showOsd(busy); + return { ok: false, message: busy }; + } + + try { + this.inProgress = true; + const result = await this.context.runWithSpinner( + () => this.context!.runManual(request), + "Subsync: syncing", + ); + this.context.showOsd(result.message); + return result; + } catch (error) { + const message = `Subsync failed: ${(error as Error).message}`; + this.context.showOsd(message); + return { ok: false, message }; + } finally { + this.inProgress = false; + } + } +} diff --git a/src/subsync/engines.ts b/src/subsync/engines.ts new file mode 100644 index 0000000..338ede0 --- /dev/null +++ b/src/subsync/engines.ts @@ -0,0 +1,95 @@ +export type SubsyncEngine = "alass" | "ffsubsync"; + +export interface SubsyncCommandResult { + ok: boolean; + code: number | null; + stderr: string; + stdout: string; + error?: string; +} + +export interface SubsyncEngineExecutionContext { + referenceFilePath: string; + videoPath: string; + inputSubtitlePath: string; + outputPath: string; + audioStreamIndex: number | null; + resolveExecutablePath: ( + configuredPath: string, + commandName: string, + ) => string; + resolvedPaths: { + alassPath: string; + ffsubsyncPath: string; + }; + runCommand: (command: string, args: string[]) => Promise; +} + +export interface SubsyncEngineProvider { + engine: SubsyncEngine; + execute: ( + context: SubsyncEngineExecutionContext, + ) => Promise; +} + +type SubsyncEngineProviderFactory = () => SubsyncEngineProvider; + +const subsyncEngineProviderFactories = new Map(); + +export function registerSubsyncEngineProvider( + engine: SubsyncEngine, + factory: SubsyncEngineProviderFactory, +): void { + if (subsyncEngineProviderFactories.has(engine)) { + return; + } + subsyncEngineProviderFactories.set(engine, factory); +} + +export function createSubsyncEngineProvider( + engine: SubsyncEngine, +): SubsyncEngineProvider | null { + const factory = subsyncEngineProviderFactories.get(engine); + if (!factory) return null; + return factory(); +} + +function registerDefaultSubsyncEngineProviders(): void { + registerSubsyncEngineProvider("alass", () => ({ + engine: "alass", + execute: async (context: SubsyncEngineExecutionContext) => { + const alassPath = context.resolveExecutablePath( + context.resolvedPaths.alassPath, + "alass", + ); + return context.runCommand(alassPath, [ + context.referenceFilePath, + context.inputSubtitlePath, + context.outputPath, + ]); + }, + })); + + registerSubsyncEngineProvider("ffsubsync", () => ({ + engine: "ffsubsync", + execute: async (context: SubsyncEngineExecutionContext) => { + const ffsubsyncPath = context.resolveExecutablePath( + context.resolvedPaths.ffsubsyncPath, + "ffsubsync", + ); + const args = [ + context.videoPath, + "-i", + context.inputSubtitlePath, + "-o", + context.outputPath, + ]; + if (context.audioStreamIndex !== null) { + args.push("--reference-stream", `0:${context.audioStreamIndex}`); + } + return context.runCommand(ffsubsyncPath, args); + }, + })); +} + +registerDefaultSubsyncEngineProviders(); diff --git a/src/subtitle/pipeline.ts b/src/subtitle/pipeline.ts new file mode 100644 index 0000000..43df702 --- /dev/null +++ b/src/subtitle/pipeline.ts @@ -0,0 +1,46 @@ +import { TokenMergerProvider } from "../token-mergers"; +import { TokenizerProvider } from "../tokenizers"; +import { SubtitleData } from "../types"; +import { + normalizeDisplayText, + normalizeTokenizerInput, +} from "./stages/normalize"; +import { tokenizeStage } from "./stages/tokenize"; +import { mergeStage } from "./stages/merge"; + +export interface SubtitlePipelineDeps { + getTokenizer: () => TokenizerProvider | null; + getTokenMerger: () => TokenMergerProvider | null; +} + +export class SubtitlePipeline { + private readonly deps: SubtitlePipelineDeps; + + constructor(deps: SubtitlePipelineDeps) { + this.deps = deps; + } + + async process(text: string): Promise { + if (!text) { + return { text, tokens: null }; + } + + const displayText = normalizeDisplayText(text); + if (!displayText) { + return { text, tokens: null }; + } + + const tokenizeText = normalizeTokenizerInput(displayText); + + try { + const tokens = await tokenizeStage(this.deps.getTokenizer(), tokenizeText); + const mergedTokens = mergeStage(this.deps.getTokenMerger(), tokens); + if (!mergedTokens || mergedTokens.length === 0) { + return { text: displayText, tokens: null }; + } + return { text: displayText, tokens: mergedTokens }; + } catch { + return { text: displayText, tokens: null }; + } + } +} diff --git a/src/subtitle/stages/merge.ts b/src/subtitle/stages/merge.ts new file mode 100644 index 0000000..04673b2 --- /dev/null +++ b/src/subtitle/stages/merge.ts @@ -0,0 +1,12 @@ +import { TokenMergerProvider } from "../../token-mergers"; +import { MergedToken, Token } from "../../types"; + +export function mergeStage( + mergerProvider: TokenMergerProvider | null, + tokens: Token[] | null, +): MergedToken[] | null { + if (!mergerProvider || !tokens || tokens.length === 0) { + return null; + } + return mergerProvider.merge(tokens); +} diff --git a/src/subtitle/stages/normalize.ts b/src/subtitle/stages/normalize.ts new file mode 100644 index 0000000..4c49d73 --- /dev/null +++ b/src/subtitle/stages/normalize.ts @@ -0,0 +1,14 @@ +export function normalizeDisplayText(text: string): string { + return text + .replace(/\r\n/g, "\n") + .replace(/\\N/g, "\n") + .replace(/\\n/g, "\n") + .trim(); +} + +export function normalizeTokenizerInput(displayText: string): string { + return displayText + .replace(/\n/g, " ") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/src/subtitle/stages/tokenize.ts b/src/subtitle/stages/tokenize.ts new file mode 100644 index 0000000..9039e2b --- /dev/null +++ b/src/subtitle/stages/tokenize.ts @@ -0,0 +1,12 @@ +import { TokenizerProvider } from "../../tokenizers"; +import { Token } from "../../types"; + +export async function tokenizeStage( + tokenizerProvider: TokenizerProvider | null, + input: string, +): Promise { + if (!tokenizerProvider || !input) { + return null; + } + return tokenizerProvider.tokenize(input); +} diff --git a/src/token-mergers/index.ts b/src/token-mergers/index.ts new file mode 100644 index 0000000..260b843 --- /dev/null +++ b/src/token-mergers/index.ts @@ -0,0 +1,42 @@ +import { mergeTokens as defaultMergeTokens } from "../token-merger"; +import { MergedToken, Token } from "../types"; + +export interface TokenMergerProvider { + id: string; + merge: (tokens: Token[]) => MergedToken[]; +} + +type TokenMergerProviderFactory = () => TokenMergerProvider; + +const tokenMergerProviderFactories = new Map(); + +export function registerTokenMergerProvider( + id: string, + factory: TokenMergerProviderFactory, +): void { + if (tokenMergerProviderFactories.has(id)) { + return; + } + tokenMergerProviderFactories.set(id, factory); +} + +export function getRegisteredTokenMergerProviderIds(): string[] { + return Array.from(tokenMergerProviderFactories.keys()); +} + +export function createTokenMergerProvider( + id = "default", +): TokenMergerProvider | null { + const factory = tokenMergerProviderFactories.get(id); + if (!factory) return null; + return factory(); +} + +function registerDefaultTokenMergerProviders(): void { + registerTokenMergerProvider("default", () => ({ + id: "default", + merge: (tokens: Token[]) => defaultMergeTokens(tokens), + })); +} + +registerDefaultTokenMergerProviders(); diff --git a/src/tokenizers/index.ts b/src/tokenizers/index.ts new file mode 100644 index 0000000..e60543b --- /dev/null +++ b/src/tokenizers/index.ts @@ -0,0 +1,53 @@ +import { MecabTokenizer } from "../mecab-tokenizer"; +import { MecabStatus, Token } from "../types"; + +export interface TokenizerProvider { + id: string; + checkAvailability: () => Promise; + tokenize: (text: string) => Promise; + getStatus: () => MecabStatus; + setEnabled: (enabled: boolean) => void; +} + +type TokenizerProviderFactory = () => TokenizerProvider; + +const tokenizerProviderFactories = new Map(); + +export function registerTokenizerProvider( + id: string, + factory: TokenizerProviderFactory, +): void { + if (tokenizerProviderFactories.has(id)) { + return; + } + tokenizerProviderFactories.set(id, factory); +} + +export function getRegisteredTokenizerProviderIds(): string[] { + return Array.from(tokenizerProviderFactories.keys()); +} + +export function createTokenizerProvider( + id = "mecab", +): TokenizerProvider | null { + const factory = tokenizerProviderFactories.get(id); + if (!factory) { + return null; + } + return factory(); +} + +function registerDefaultTokenizerProviders(): void { + registerTokenizerProvider("mecab", () => { + const mecab = new MecabTokenizer(); + return { + id: "mecab", + checkAvailability: () => mecab.checkAvailability(), + tokenize: (text: string) => mecab.tokenize(text), + getStatus: () => mecab.getStatus(), + setEnabled: (enabled: boolean) => mecab.setEnabled(enabled), + }; + }); +} + +registerDefaultTokenizerProviders(); diff --git a/src/translators/index.ts b/src/translators/index.ts new file mode 100644 index 0000000..64bcb1a --- /dev/null +++ b/src/translators/index.ts @@ -0,0 +1,106 @@ +import axios from "axios"; + +export interface TranslationRequest { + sentence: string; + apiKey: string; + baseUrl: string; + model: string; + targetLanguage: string; + systemPrompt: string; + timeoutMs?: number; +} + +export interface TranslationProvider { + id: string; + translate: (request: TranslationRequest) => Promise; +} + +type TranslationProviderFactory = () => TranslationProvider; + +const translationProviderFactories = new Map(); + +export function registerTranslationProvider( + id: string, + factory: TranslationProviderFactory, +): void { + if (translationProviderFactories.has(id)) { + return; + } + translationProviderFactories.set(id, factory); +} + +export function createTranslationProvider( + id = "openai-compatible", +): TranslationProvider | null { + const factory = translationProviderFactories.get(id); + if (!factory) return null; + return factory(); +} + +function extractAiText(content: unknown): string { + if (typeof content === "string") { + return content.trim(); + } + if (!Array.isArray(content)) { + return ""; + } + const parts: string[] = []; + for (const item of content) { + if ( + item && + typeof item === "object" && + "type" in item && + (item as { type?: unknown }).type === "text" && + "text" in item && + typeof (item as { text?: unknown }).text === "string" + ) { + parts.push((item as { text: string }).text); + } + } + return parts.join("").trim(); +} + +function normalizeOpenAiBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + if (/\/v1$/i.test(trimmed)) { + return trimmed; + } + return `${trimmed}/v1`; +} + +function registerDefaultTranslationProviders(): void { + registerTranslationProvider("openai-compatible", () => ({ + id: "openai-compatible", + translate: async (request: TranslationRequest): Promise => { + const response = await axios.post( + `${normalizeOpenAiBaseUrl(request.baseUrl)}/chat/completions`, + { + model: request.model, + temperature: 0, + messages: [ + { role: "system", content: request.systemPrompt }, + { + role: "user", + content: `Translate this text to ${request.targetLanguage}:\n\n${request.sentence}`, + }, + ], + }, + { + headers: { + Authorization: `Bearer ${request.apiKey}`, + "Content-Type": "application/json", + }, + timeout: request.timeoutMs ?? 15000, + }, + ); + + const content = (response.data as { choices?: unknown[] })?.choices?.[0] as + | { message?: { content?: unknown } } + | undefined; + const translated = extractAiText(content?.message?.content); + return translated || null; + }, + })); +} + +registerDefaultTranslationProviders(); From b5fcd4f0728b9d11149ed081d0cd30591c658c8f Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:17:56 -0800 Subject: [PATCH 09/74] docs: align architecture and contributor guidance with current services --- docs/architecture.md | 10 +++++----- docs/development.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index be179ba..07908a3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,7 +23,7 @@ SubMiner uses a service-oriented Electron main-process architecture where `src/m - Provides a narrow state API used by `main.ts` and overlay services. - `src/core/services/*` - Stateless or narrowly stateful units for a specific responsibility. - - Examples: startup bootstrap, app lifecycle hooks, CLI command handling, IPC registration, overlay visibility, MPV IPC behavior, shortcut registration, subtitle websocket, jimaku/subsync helpers. + - Examples: startup bootstrap/ready flow, app lifecycle wiring, CLI command handling, IPC registration, overlay visibility, MPV IPC behavior, shortcut registration, subtitle websocket, jimaku/subsync helpers. - `src/core/utils/*` - Pure helpers and coercion/config utilities. - `src/cli/*` @@ -41,7 +41,7 @@ Most runtime code follows a dependency-injection pattern: 1. Define a service interface in `src/core/services/*`. 2. Keep core logic in pure or side-effect-bounded functions. -3. Build runtime deps in `main.ts`; use `*-deps-runtime-service.ts` helpers only when they add real adaptation logic. +3. Build runtime deps in `main.ts`; extract an adapter/helper only when it adds meaningful behavior or reuse. 4. Call the service from lifecycle/command wiring points. This keeps side effects explicit and makes behavior easy to unit-test with fakes. @@ -49,15 +49,15 @@ This keeps side effects explicit and makes behavior easy to unit-test with fakes ## Lifecycle Model - Startup: - - `startup-bootstrap-runtime-service` handles initial argv/env/backend setup and decides generate-config flow vs app lifecycle start. + - `runStartupBootstrapRuntimeService` handles initial argv/env/backend setup and decides generate-config flow vs app lifecycle start. - `app-lifecycle-service` handles Electron single-instance + lifecycle event registration. - - `startup-lifecycle-hooks-runtime-service` wires app-ready and app-shutdown hooks. + - `runAppReadyRuntimeService` performs ready-time initialization (config load, websocket policy, tokenizer/tracker setup, overlay auto-init decisions). - Runtime: - CLI/shortcut/IPC events map to service calls. - Overlay and MPV state sync through dedicated services. - Runtime options and mining flows are coordinated via service boundaries. - Shutdown: - - `app-shutdown-runtime-service` coordinates cleanup ordering (shortcuts, sockets, trackers, integrations). + - `startAppLifecycleService` registers cleanup hooks (`will-quit`) while teardown behavior stays delegated to focused services from `main.ts`. ## Why This Design diff --git a/docs/development.md b/docs/development.md index 69acbaa..3ea5724 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,7 +9,7 @@ The current runtime design, composition model, and extension guidelines are docu Contributor guidance: - Overlay window/visibility state is owned by `src/core/services/overlay-manager-service.ts`. - Prefer direct inline deps objects in `main.ts` for simple pass-through wiring. -- Add a `*-deps-runtime-service.ts` helper only when it performs meaningful adaptation (not identity mapping). +- Add a helper/adapter service only when it performs meaningful adaptation, validation, or reuse (not identity mapping). ## Environment Variables From a37ab476dd4448db08b9948d39a615cec4a6da25 Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:20:59 -0800 Subject: [PATCH 10/74] docs: add Mermaid architecture diagrams and VitePress renderer --- docs/.vitepress/theme/index.ts | 75 +++++++++++++++++++++++++++++++++- docs/architecture.md | 40 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index a8ae85d..cb9f3f2 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,4 +1,77 @@ import DefaultTheme from 'vitepress/theme'; +import { useRoute } from 'vitepress'; +import { nextTick, onMounted, watch } from 'vue'; import '@catppuccin/vitepress/theme/macchiato/mauve.css'; -export default DefaultTheme; +let mermaidLoader: Promise | null = null; + +async function getMermaid() { + if (!mermaidLoader) { + mermaidLoader = import( + /* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs' + ).then((mod) => { + const mermaid = mod.default ?? mod; + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'loose', + }); + return mermaid; + }); + } + return mermaidLoader; +} + +async function renderMermaidBlocks() { + if (typeof document === 'undefined') { + return; + } + const blocks = Array.from( + document.querySelectorAll('div.language-mermaid'), + ); + if (blocks.length === 0) { + return; + } + + const mermaid = await getMermaid(); + const nodes: HTMLElement[] = []; + + for (const block of blocks) { + if (block.dataset.mermaidRendered === 'true') { + continue; + } + const code = block.querySelector('pre code'); + const source = code?.textContent?.trim(); + if (!source) { + continue; + } + + const mount = document.createElement('div'); + mount.className = 'mermaid'; + mount.textContent = source; + + block.replaceChildren(mount); + block.dataset.mermaidRendered = 'true'; + nodes.push(mount); + } + + if (nodes.length > 0) { + await mermaid.run({ nodes }); + } +} + +export default { + ...DefaultTheme, + setup() { + const route = useRoute(); + const render = () => { + nextTick(() => { + renderMermaidBlocks().catch((error) => { + console.error('Failed to render Mermaid diagram:', error); + }); + }); + }; + + onMounted(render); + watch(() => route.path, render); + }, +}; diff --git a/docs/architecture.md b/docs/architecture.md index 07908a3..b18cf18 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,6 +35,33 @@ SubMiner uses a service-oriented Electron main-process architecture where `src/m - `src/jimaku/*`, `src/subsync/*` - Domain-specific integration helpers. +## Flow Diagram + +```mermaid +flowchart TD + Main["src/main.ts\n(composition root)"] --> Startup["runStartupBootstrapRuntimeService"] + Main --> Lifecycle["startAppLifecycleService"] + Lifecycle --> AppReady["runAppReadyRuntimeService"] + + Main --> OverlayMgr["overlay-manager-service"] + Main --> Ipc["ipc-service / ipc-command-service"] + Main --> Mpv["mpv-service / mpv-control-service"] + Main --> Shortcuts["shortcut-service / overlay-shortcut-service"] + Main --> RuntimeOpts["runtime-options-ipc-service"] + Main --> Subtitle["subtitle-ws-service / secondary-subtitle-service"] + + Main --> Config["src/config/*"] + Main --> Cli["src/cli/*"] + Main --> Trackers["src/window-trackers/*"] + Main --> Integrations["src/jimaku/* + src/subsync/*"] + + OverlayMgr --> OverlayWindow["overlay-window-service"] + OverlayMgr --> OverlayVisibility["overlay-visibility-service"] + Mpv --> Subtitle + Ipc --> RuntimeOpts + Shortcuts --> OverlayMgr +``` + ## Composition Pattern Most runtime code follows a dependency-injection pattern: @@ -59,6 +86,19 @@ This keeps side effects explicit and makes behavior easy to unit-test with fakes - Shutdown: - `startAppLifecycleService` registers cleanup hooks (`will-quit`) while teardown behavior stays delegated to focused services from `main.ts`. +```mermaid +flowchart LR + Args["CLI args"] --> Bootstrap["runStartupBootstrapRuntimeService"] + Bootstrap -->|generate-config| Exit["exit"] + Bootstrap -->|normal start| AppLifecycle["startAppLifecycleService"] + AppLifecycle --> Ready["runAppReadyRuntimeService"] + Ready --> Runtime["IPC + shortcuts + mpv events"] + Runtime --> Overlay["overlay visibility + mining actions"] + Runtime --> Subsync["subsync + secondary sub flows"] + Runtime --> WillQuit["app will-quit"] + WillQuit --> Cleanup["service-level cleanup + unregister"] +``` + ## Why This Design - Smaller blast radius: changing one feature usually touches one service. From 9d49e9eaa85739698a64aee10e72c354b9294453 Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:24:05 -0800 Subject: [PATCH 11/74] docs: bundle mermaid locally for offline diagram rendering --- docs/.vitepress/theme/index.ts | 6 +- package.json | 5 +- pnpm-lock.yaml | 943 ++++++++++++++++++++++++++++++++- 3 files changed, 935 insertions(+), 19 deletions(-) diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index cb9f3f2..0c527da 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,16 +1,14 @@ import DefaultTheme from 'vitepress/theme'; import { useRoute } from 'vitepress'; import { nextTick, onMounted, watch } from 'vue'; +import mermaid from 'mermaid'; import '@catppuccin/vitepress/theme/macchiato/mauve.css'; let mermaidLoader: Promise | null = null; async function getMermaid() { if (!mermaidLoader) { - mermaidLoader = import( - /* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs' - ).then((mod) => { - const mermaid = mod.default ?? mod; + mermaidLoader = Promise.resolve().then(() => { mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', diff --git a/package.json b/package.json index 1e84382..189f749 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,11 @@ "author": "", "license": "GPL-3.0-or-later", "dependencies": { + "@catppuccin/vitepress": "^0.1.2", "axios": "^1.13.5", "jsonc-parser": "^3.3.1", - "ws": "^8.19.0", - "@catppuccin/vitepress": "^0.1.2" + "mermaid": "^11.12.2", + "ws": "^8.19.0" }, "devDependencies": { "@types/node": "^25.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de79ab0..6b11cf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: jsonc-parser: specifier: ^3.3.1 version: 3.3.1 + mermaid: + specifier: ^11.12.2 + version: 11.12.2 ws: specifier: ^8.19.0 version: 8.19.0 @@ -121,6 +124,9 @@ packages: resolution: {integrity: sha512-ZgxV2+5qt3NLeUYBTsi6PLyHcENQWC0iFppFZekHSEDA2wcLdTUjnaJzimTEULHIvJuLRCkUs4JABdhuJktEag==} engines: {node: '>= 14.0.0'} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -138,11 +144,29 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@catppuccin/vitepress@0.1.2': resolution: {integrity: sha512-dqhgo6U6GWbgh3McAgwemUC8Y2Aj48rRcQx/9iuPzBPAgo7NA3yi7ZcR0wolAENMmoOMAHBV+rz/5DfiGxtZLA==} peerDependencies: typescript: ^5.0.0 + '@chevrotain/cst-dts-gen@11.0.3': + resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} + + '@chevrotain/gast@11.0.3': + resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} + + '@chevrotain/regexp-to-ast@11.0.3': + resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} + + '@chevrotain/types@11.0.3': + resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} + + '@chevrotain/utils@11.0.3': + resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@develar/schema-utils@2.6.5': resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} @@ -354,6 +378,9 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -381,6 +408,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@mermaid-js/parser@0.6.3': + resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@npmcli/agent@3.0.0': resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} engines: {node: ^18.17.0 || >=20.5.0} @@ -427,79 +457,66 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -566,6 +583,99 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -575,6 +685,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -611,6 +724,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -732,6 +848,11 @@ packages: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -869,6 +990,14 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.0.3: + resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -925,6 +1054,14 @@ packages: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -936,6 +1073,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -943,6 +1083,12 @@ packages: core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crc@3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} @@ -956,6 +1102,165 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.13: + resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -984,6 +1289,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1014,6 +1322,9 @@ packages: os: [darwin] hasBin: true + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -1247,6 +1558,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1320,6 +1634,13 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -1394,12 +1715,35 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + katex@0.16.28: + resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + langium@3.3.1: + resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} + engines: {node: '>=16.0.0'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -1428,6 +1772,11 @@ packages: mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -1439,6 +1788,9 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mermaid@11.12.2: + resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -1539,6 +1891,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1608,6 +1963,12 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -1620,6 +1981,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} @@ -1637,10 +2001,19 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1736,11 +2109,20 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -1866,6 +2248,9 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -1895,6 +2280,10 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1912,6 +2301,10 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -1921,6 +2314,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -1967,6 +2363,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -2020,6 +2420,26 @@ packages: postcss: optional: true + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.0.8: + resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + vue@3.5.28: resolution: {integrity: sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==} peerDependencies: @@ -2213,6 +2633,11 @@ snapshots: dependencies: '@algolia/client-common': 5.48.0 + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -2226,10 +2651,29 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@braintree/sanitize-url@7.1.2': {} + '@catppuccin/vitepress@0.1.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 + '@chevrotain/cst-dts-gen@11.0.3': + dependencies: + '@chevrotain/gast': 11.0.3 + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/gast@11.0.3': + dependencies: + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/regexp-to-ast@11.0.3': {} + + '@chevrotain/types@11.0.3': {} + + '@chevrotain/utils@11.0.3': {} + '@develar/schema-utils@2.6.5': dependencies: ajv: 6.12.6 @@ -2434,6 +2878,12 @@ snapshots: '@iconify/types@2.0.0': {} + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.0 + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.1': @@ -2468,6 +2918,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@mermaid-js/parser@0.6.3': + dependencies: + langium: 3.3.1 + '@npmcli/agent@3.0.0': dependencies: agent-base: 7.1.4 @@ -2613,6 +3067,123 @@ snapshots: '@types/node': 25.2.2 '@types/responselike': 1.0.3 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -2623,6 +3194,8 @@ snapshots: dependencies: '@types/node': 25.2.2 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -2666,6 +3239,9 @@ snapshots: dependencies: '@types/node': 25.2.2 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@types/verror@1.10.11': @@ -2793,6 +3369,8 @@ snapshots: abbrev@3.0.1: {} + acorn@8.15.0: {} + agent-base@7.1.4: {} ajv-keywords@3.5.2(ajv@6.12.6): @@ -3006,6 +3584,20 @@ snapshots: character-entities-legacy@3.0.0: {} + chevrotain-allstar@0.3.1(chevrotain@11.0.3): + dependencies: + chevrotain: 11.0.3 + lodash-es: 4.17.23 + + chevrotain@11.0.3: + dependencies: + '@chevrotain/cst-dts-gen': 11.0.3 + '@chevrotain/gast': 11.0.3 + '@chevrotain/regexp-to-ast': 11.0.3 + '@chevrotain/types': 11.0.3 + '@chevrotain/utils': 11.0.3 + lodash-es: 4.17.21 + chownr@3.0.0: {} chromium-pickle-js@0.2.0: {} @@ -3052,6 +3644,10 @@ snapshots: commander@5.1.0: {} + commander@7.2.0: {} + + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -3059,6 +3655,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 @@ -3066,6 +3664,14 @@ snapshots: core-util-is@1.0.2: optional: true + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + crc@3.8.0: dependencies: buffer: 5.7.1 @@ -3082,6 +3688,192 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.13: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + + dayjs@1.11.19: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3110,6 +3902,10 @@ snapshots: object-keys: 1.1.1 optional: true + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + delayed-stream@1.0.0: {} dequal@2.0.3: {} @@ -3153,6 +3949,10 @@ snapshots: verror: 1.10.1 optional: true + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv-expand@11.0.7: dependencies: dotenv: 16.6.1 @@ -3469,6 +4269,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -3554,6 +4356,10 @@ snapshots: inherits@2.0.4: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + ip-address@10.1.0: {} is-fullwidth-code-point@3.0.0: {} @@ -3611,12 +4417,34 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + katex@0.16.28: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + + langium@3.3.1: + dependencies: + chevrotain: 11.0.3 + chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lazy-val@1.0.5: {} + lodash-es@4.17.21: {} + + lodash-es@4.17.23: {} + lodash@4.17.23: {} log-symbols@4.1.0: @@ -3654,6 +4482,8 @@ snapshots: mark.js@8.11.1: {} + marked@16.4.2: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -3673,6 +4503,29 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + mermaid@11.12.2: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 0.6.3 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.13 + dayjs: 1.11.19 + dompurify: 3.3.1 + katex: 0.16.28 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -3764,6 +4617,13 @@ snapshots: dependencies: minimist: 1.2.8 + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + ms@2.1.3: {} nanoid@3.3.11: {} @@ -3841,6 +4701,10 @@ snapshots: package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} + + path-data-parser@0.1.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -3850,6 +4714,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + pathe@2.0.3: {} + pe-library@0.4.1: {} pend@1.2.0: {} @@ -3860,12 +4726,25 @@ snapshots: picomatch@4.0.3: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 base64-js: 1.5.1 xmlbuilder: 15.1.1 + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -3964,6 +4843,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.2: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -3995,6 +4876,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -4119,6 +5009,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + stylis@4.3.6: {} + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -4157,6 +5049,8 @@ snapshots: dependencies: semver: 5.7.2 + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -4174,11 +5068,15 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-dedent@2.2.0: {} + type-fest@0.13.1: optional: true typescript@5.9.3: {} + ufo@1.6.3: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -4226,6 +5124,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 @@ -4301,6 +5201,23 @@ snapshots: - typescript - universal-cookie + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.0.8: {} + vue@3.5.28(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.28 From dc54daa79dd70107a17efac97325fc0f7688bcdc Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:28:32 -0800 Subject: [PATCH 12/74] feat(docs): add interactive Mermaid diagram modal --- docs/.vitepress/theme/index.ts | 100 ++++++++++++++++++++++++ docs/.vitepress/theme/mermaid-modal.css | 69 ++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 docs/.vitepress/theme/mermaid-modal.css diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 0c527da..257b89e 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -3,8 +3,107 @@ import { useRoute } from 'vitepress'; import { nextTick, onMounted, watch } from 'vue'; import mermaid from 'mermaid'; import '@catppuccin/vitepress/theme/macchiato/mauve.css'; +import './mermaid-modal.css'; let mermaidLoader: Promise | null = null; +const MERMAID_MODAL_ID = 'mermaid-diagram-modal'; + +function closeMermaidModal() { + if (typeof document === 'undefined') { + return; + } + + const modal = document.getElementById(MERMAID_MODAL_ID); + if (!modal) { + return; + } + + modal.classList.remove('is-open'); + document.body.classList.remove('mermaid-modal-open'); +} + +function ensureMermaidModal(): HTMLDivElement { + const existing = document.getElementById(MERMAID_MODAL_ID); + if (existing) { + return existing as HTMLDivElement; + } + + const modal = document.createElement('div'); + modal.id = MERMAID_MODAL_ID; + modal.className = 'mermaid-modal'; + modal.innerHTML = ` +
+ + `; + + modal.addEventListener('click', (event) => { + const target = event.target as HTMLElement | null; + if (!target) { + return; + } + + if (target.closest('[data-mermaid-close="true"]') || target.closest('.mermaid-modal__close')) { + closeMermaidModal(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && modal.classList.contains('is-open')) { + closeMermaidModal(); + } + }); + + document.body.appendChild(modal); + return modal; +} + +function openMermaidModal(sourceNode: HTMLElement) { + if (typeof document === 'undefined') { + return; + } + + const modal = ensureMermaidModal(); + const content = modal.querySelector('.mermaid-modal__content'); + if (!content) { + return; + } + + content.replaceChildren(sourceNode.cloneNode(true)); + modal.classList.add('is-open'); + document.body.classList.add('mermaid-modal-open'); +} + +function attachMermaidInteractions(nodes: HTMLElement[]) { + for (const node of nodes) { + if (node.dataset.mermaidInteractive === 'true') { + continue; + } + + const svg = node.querySelector('svg'); + if (!svg) { + continue; + } + + node.classList.add('mermaid-interactive'); + node.setAttribute('role', 'button'); + node.setAttribute('tabindex', '0'); + node.setAttribute('aria-label', 'Open Mermaid diagram in full view'); + + const open = () => openMermaidModal(svg); + node.addEventListener('click', open); + node.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + open(); + } + }); + + node.dataset.mermaidInteractive = 'true'; + } +} async function getMermaid() { if (!mermaidLoader) { @@ -54,6 +153,7 @@ async function renderMermaidBlocks() { if (nodes.length > 0) { await mermaid.run({ nodes }); + attachMermaidInteractions(nodes); } } diff --git a/docs/.vitepress/theme/mermaid-modal.css b/docs/.vitepress/theme/mermaid-modal.css new file mode 100644 index 0000000..0c844d2 --- /dev/null +++ b/docs/.vitepress/theme/mermaid-modal.css @@ -0,0 +1,69 @@ +.mermaid-interactive { + cursor: zoom-in; +} + +.mermaid-interactive:focus-visible { + outline: 2px solid var(--vp-c-brand-1); + outline-offset: 4px; + border-radius: 6px; +} + +.mermaid-modal { + position: fixed; + inset: 0; + z-index: 200; + display: none; +} + +.mermaid-modal.is-open { + display: block; +} + +.mermaid-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.72); +} + +.mermaid-modal__dialog { + position: relative; + z-index: 1; + margin: 4vh auto; + width: min(96vw, 1800px); + max-height: 92vh; + border: 1px solid var(--vp-c-border); + border-radius: 12px; + background: var(--vp-c-bg); + box-shadow: var(--vp-shadow-4); + overflow: hidden; +} + +.mermaid-modal__close { + display: block; + margin-left: auto; + margin-right: 16px; + margin-top: 12px; + border: 1px solid var(--vp-c-border); + border-radius: 6px; + padding: 4px 10px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-text-1); + font-size: 14px; +} + +.mermaid-modal__content { + overflow: auto; + max-height: calc(92vh - 56px); + padding: 8px 16px 16px; +} + +.mermaid-modal__content svg { + max-width: none; + width: max-content; + height: auto; + min-width: 100%; +} + +body.mermaid-modal-open { + overflow: hidden; +} From b6f3d0aad3eac68d5d9eb23525ac30735cfca1ee Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 18:31:36 -0800 Subject: [PATCH 13/74] add investigation --- investigation.md | 219 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 investigation.md diff --git a/investigation.md b/investigation.md new file mode 100644 index 0000000..4266383 --- /dev/null +++ b/investigation.md @@ -0,0 +1,219 @@ +# Refactoring Investigation Report + +## Overview + +This report evaluates the SubMiner refactoring effort on the `refactor` branch against the plan defined in `plan.md` and the safety checklist in `docs/refactor-main-checklist.md`. The refactoring aimed to eliminate unnecessary abstraction layers, consolidate related services, fix known bugs, and add test coverage. + +**Result**: 65 commits, 104 files changed, main.ts reduced from ~5,800 to 1,398 lines, all 67 tests passing, build succeeds. + +--- + +## Phase 1: Delete Thin Wrappers — COMPLETE + +All 9 target wrapper services and 7 associated test files have been deleted and their logic inlined into callers (mostly main.ts or the services they fed). + +| Target File | Status | +|-------------|--------| +| `config-warning-runtime-service.ts` + test | Deleted, inlined | +| `overlay-modal-restore-service.ts` + test | Deleted, inlined | +| `runtime-options-manager-runtime-service.ts` + test | Deleted, inlined | +| `app-logging-runtime-service.ts` + test | Deleted, inlined | +| `overlay-send-service.ts` (no test) | Deleted, inlined | +| `startup-resource-runtime-service.ts` + test | Deleted, inlined | +| `config-generation-runtime-service.ts` + test | Deleted, inlined | +| `app-shutdown-runtime-service.ts` + test | Deleted, inlined | +| `shortcut-ui-deps-runtime-service.ts` + test | Deleted, inlined | + +**Files removed**: 16 (9 services + 7 tests) +**No issues found.** + +--- + +## Phase 2: Consolidate DI Adapter Services — COMPLETE + +All 4 dependency-injection adapter services have been merged into the services they fed, and their test files removed. + +| Adapter | Merged Into | Status | +|---------|------------|--------| +| `cli-command-deps-runtime-service.ts` + test | `cli-command-service.ts` | Done | +| `ipc-deps-runtime-service.ts` + test | `ipc-service.ts` | Done | +| `tokenizer-deps-runtime-service.ts` + test | `tokenizer-service.ts` | Done | +| `app-lifecycle-deps-runtime-service.ts` + test | `app-lifecycle-service.ts` | Done | + +**Files removed**: 8 (4 services + 4 tests) +**No issues found.** + +--- + +## Phase 3: Consolidate Related Services — COMPLETE + +All planned service merges have been executed. + +| Consolidation | Source Files | Result File | Status | +|--------------|-------------|-------------|--------| +| Overlay visibility | `overlay-visibility-runtime-service.ts` | `overlay-visibility-service.ts` | Done | +| Overlay broadcast | `overlay-broadcast-runtime-service.ts` + test | `overlay-manager-service.ts` | Done | +| Overlay shortcuts | `overlay-shortcut-lifecycle-service.ts`, `overlay-shortcut-fallback-runner.ts` | `overlay-shortcut-service.ts` + `overlay-shortcut-handler.ts` | Done | +| Numeric shortcuts | `numeric-shortcut-runtime-service.ts` + test, `numeric-shortcut-session-service.ts` | `numeric-shortcut-service.ts` | Done | +| Startup/bootstrap | `startup-bootstrap-runtime-service.ts` + test, `app-ready-runtime-service.ts` + test | `startup-service.ts` | Done | + +**Files removed**: ~10 +**No issues found.** + +--- + +## Phase 4: Fix Bugs and Code Quality — COMPLETE + +### 4.1 Debug console.log statements +**Status**: RESOLVED — No debug `console.log` or `console.warn` calls remain in `overlay-visibility-service.ts`. + +### 4.2 `as never` type cast +**Status**: RESOLVED — No `as never` cast remains in `tokenizer-service.ts`. The type mismatch was fixed properly. + +### 4.3 fieldGroupingResolver race condition +**Status**: RESOLVED — Fixed in `main.ts` (lines 305–332) with a sequence counter mechanism. Each new resolver increments `fieldGroupingResolverSequence`, and wrapped resolvers check if their sequence matches the current value before executing. Stale resolutions are correctly ignored. + +### 4.4 Async callback safety +**Status**: RESOLVED +- **CLI commands** (`cli-command-service.ts`): Async commands use `runAsyncWithOsd` helper (lines 177–187) which catches errors, logs them, and displays via MPV OSD. +- **IPC handlers** (`ipc-service.ts`): Async handlers use `ipcMain.handle` (not `.on`), which properly awaits and propagates promise results. Synchronous handlers correctly use `ipcMain.on`. + +### 4.5 `-runtime-service` naming convention +**Status**: RESOLVED — No files with `-runtime-service` in the name exist under `src/core/services/`. All have been renamed to `*-service.ts`. + +--- + +## Phase 5: Add Tests for Critical Untested Services — COMPLETE + +Tests added per plan: + +| Service | Test File | Tests | +|---------|-----------|-------| +| `mpv-service.ts` (761 lines) | `mpv-service.test.ts` | Socket protocol, property changes, reconnection | +| `subsync-service.ts` (427 lines) | `subsync-service.test.ts` | Config resolution, command construction, error handling | +| `tokenizer-service.ts` (305 lines) | `tokenizer-service.test.ts` | Parser init, token extraction, edge cases | +| `cli-command-service.ts` (204 lines) | `cli-command-service.test.ts` (290 lines) | Expanded: all dispatch paths, error propagation | + +**Total**: 67 tests passing across all test suites. + +--- + +## Phase 6: Directory Restructure — NOT STARTED (Optional) + +Services remain in the flat `src/core/services/` directory. This phase was explicitly marked optional and low-priority. The current flat structure with ~25 files is manageable. + +--- + +## Checklist Compliance (docs/refactor-main-checklist.md) + +### Invariants + +| Invariant | Status | +|-----------|--------| +| CLI flags and aliases working | Verified via tests | +| IPC channel names backward-compatible | No channel names changed | +| Overlay toggle behavior preserved | Logic moved verbatim | +| MPV integration behavior preserved | Logic moved verbatim, tested | +| Texthooker mode preserved | Not altered | +| Mining/runtime options paths preserved | Logic moved verbatim | + +### Automated Checks + +| Check | Status | +|-------|--------| +| `pnpm run build` | Passes | +| `pnpm run test:core` | 67/67 passing | + +### Manual Smoke Checks + +**Status**: NOT YET PERFORMED — Visual overlay rendering, card mining flow, and field-grouping interaction require a real desktop session with MPV. Automated tests validate logic correctness but cannot catch rendering regressions. + +--- + +## Loose Ends and Concerns + +### 1. Unused Architectural Scaffolding (~500 lines, dead code) + +The following files exist in the repository but are **not imported or used anywhere**: + +**Core abstractions:** +| File | Lines | Purpose | +|------|-------|---------| +| `src/core/action-bus.ts` | 22 | Generic action dispatcher (`register`/`dispatch`) | +| `src/core/actions.ts` | 17 | Union type `AppAction` with 16 action variants | +| `src/core/app-context.ts` | 46 | Module context interfaces | +| `src/core/module-registry.ts` | 37 | Module lifecycle manager (init/start/stop) | +| `src/core/module.ts` | 7 | `SubminerModule` interface | + +**Module implementations:** +| File | Lines | Purpose | +|------|-------|---------| +| `src/modules/runtime-options/module.ts` | 62 | Runtime options module | +| `src/modules/subsync/module.ts` | 79 | Subsync module | +| `src/modules/jimaku/module.ts` | 73 | Jimaku module | + +**IPC abstraction layer:** +| File | Lines | Purpose | +|------|-------|---------| +| `src/ipc/contract.ts` | 62 | IPC channel name constants | +| `src/ipc/main-api.ts` | 20 | Main process IPC helpers | +| `src/ipc/renderer-api.ts` | 28 | Renderer process IPC helpers | + +These represent scaffolding for a module-based architecture that was prototyped but never wired into main.ts. The current codebase uses the service-oriented approach throughout. + +**Recommendation**: Remove if abandoned, or document with clear intent if planned for a future phase. + +### 2. New Consolidated Services Without Test Coverage + +Seven service files created or consolidated during Phase 3 lack dedicated tests: + +| File | Lines | Risk Level | Reason | +|------|-------|------------|--------| +| `overlay-shortcut-handler.ts` | 216 | Higher | Complex runtime handlers with fallback logic | +| `mining-service.ts` | 179 | Higher | 6 public functions orchestrating mining workflows | +| `anki-jimaku-service.ts` | 173 | Higher | Complex IPC registration with multiple handlers | +| `startup-service.ts` | ~150 | Medium | Bootstrap and app-ready orchestration | +| `numeric-shortcut-service.ts` | 133 | Medium | Session state management | +| `subsync-runner-service.ts` | 86 | Lower | Thin runtime wrapper | +| `jimaku-service.ts` | 81 | Lower | Config accessor functions | + +Phase 5 targeted the 4 highest-risk *previously existing* untested services. The Phase 3 consolidation created new files that inherited logic from multiple sources — those were not explicitly called out in the plan for testing. + +### 3. Null Safety in Consolidated Services + +All checked consolidated services handle null/undefined correctly: + +- **`mpv-control-service.ts`**: Accepts `MpvRuntimeClientLike | null`, uses null checks and optional chaining before all access. +- **`overlay-bridge-service.ts`**: Returns `false` early if window is null or destroyed (`line 17: if (!options.mainWindow || options.mainWindow.isDestroyed()) return false`). +- **`startup-service.ts`**: Uses dependency injection with all deps provided upfront, async operations awaited in sequence, config access uses optional chaining. + +**No null safety issues found.** + +### 4. Import Consistency + +All 68 imports in `main.ts` from `./core/services` resolve to existing exports. No references to deleted files remain. The barrel export in `index.ts` has 79 entries with no dead exports (one minor case: `isGlobalShortcutRegisteredSafe` is only used within the service layer itself, not by main.ts). + +--- + +## Risk Assessment + +| Area | Risk | Mitigation | +|------|------|------------| +| Logic regression from inlining | Low | Code moved verbatim, 67 tests pass | +| Overlay rendering regression | Medium | Requires manual smoke test with MPV | +| Unused scaffolding becoming stale | Low | Remove or document with clear intent | +| Missing test coverage on new files | Medium | Add tests for the 3 higher-risk services | +| Race conditions | Low | fieldGroupingResolver race fixed with sequence counter | +| Async error swallowing | Low | All async paths have error boundaries | + +--- + +## Recommendations + +1. **Remove unused scaffolding** (`src/core/action-bus.ts`, `src/core/actions.ts`, `src/core/app-context.ts`, `src/core/module-registry.ts`, `src/core/module.ts`, `src/modules/`, `src/ipc/`) — ~500 lines of dead code that contradicts the refactoring goal of reducing unnecessary abstraction. + +2. **Add tests for higher-risk consolidated services** — `overlay-shortcut-handler.ts`, `mining-service.ts`, and `anki-jimaku-service.ts` have the most complex logic among the untested new files. + +3. **Perform desktop smoke test** — Verify overlay rendering, card mining, and field-grouping interaction in a real session with MPV running. + +4. **Consider removing `isGlobalShortcutRegisteredSafe` from barrel export** — It's only used internally by the service layer, not by main.ts. From cfdc6668dfcb34f8b91027c48d0943fb4b2ba0cb Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 19:48:23 -0800 Subject: [PATCH 14/74] Complete runtime service follow-ups and invisible subtitle edit mode --- ...- Refactor-runtime-services-per-plan.md.md | 3 +- ...-1-Remove-thin-wrapper-runtime-services.md | 3 +- ...r-runtime-services-into-target-services.md | 3 +- ...e-3-Consolidate-related-service-modules.md | 3 +- ...ime-bugs-and-naming-code-quality-issues.md | 3 +- ...al-behavior-tests-for-untested-services.md | 3 +- ...organize-services-by-domain-directories.md | 3 +- ...factor-follow-ups-from-investigation.md.md | 53 ++++ ...ve-unused-scaffolding-and-clean-exports.md | 59 ++++ ...ts-for-overlay-shortcut-handler-service.md | 46 ++++ ...task-2.3 - Add-tests-for-mining-service.md | 45 +++ ...2.4 - Add-tests-for-anki-jimaku-service.md | 49 ++++ ...rform-desktop-smoke-validation-with-mpv.md | 58 ++++ .../task-3 - move-invisible-subtitles.md | 28 ++ package.json | 2 +- src/core/action-bus.ts | 21 -- src/core/actions.ts | 16 -- src/core/app-context.ts | 45 --- src/core/module-registry.ts | 36 --- src/core/module.ts | 6 - src/core/services/anki-jimaku-service.test.ts | 228 ++++++++++++++++ src/core/services/anki-jimaku-service.ts | 3 +- src/core/services/index.ts | 2 +- src/core/services/mining-service.test.ts | 168 ++++++++++++ .../services/overlay-shortcut-handler.test.ts | 256 ++++++++++++++++++ .../services/subtitle-position-service.ts | 15 +- src/ipc/contract.ts | 61 ----- src/ipc/main-api.ts | 19 -- src/ipc/renderer-api.ts | 27 -- src/modules/jimaku/module.ts | 72 ----- src/modules/runtime-options/module.ts | 61 ----- src/modules/subsync/module.ts | 78 ------ src/renderer/renderer.ts | 244 ++++++++++++++++- src/renderer/style.css | 33 +++ src/types.ts | 2 + 35 files changed, 1293 insertions(+), 461 deletions(-) create mode 100644 backlog/tasks/task-2 - Post-refactor-follow-ups-from-investigation.md.md create mode 100644 backlog/tasks/task-2.1 - Remove-unused-scaffolding-and-clean-exports.md create mode 100644 backlog/tasks/task-2.2 - Add-tests-for-overlay-shortcut-handler-service.md create mode 100644 backlog/tasks/task-2.3 - Add-tests-for-mining-service.md create mode 100644 backlog/tasks/task-2.4 - Add-tests-for-anki-jimaku-service.md create mode 100644 backlog/tasks/task-2.5 - Perform-desktop-smoke-validation-with-mpv.md create mode 100644 backlog/tasks/task-3 - move-invisible-subtitles.md delete mode 100644 src/core/action-bus.ts delete mode 100644 src/core/actions.ts delete mode 100644 src/core/app-context.ts delete mode 100644 src/core/module-registry.ts delete mode 100644 src/core/module.ts create mode 100644 src/core/services/anki-jimaku-service.test.ts create mode 100644 src/core/services/mining-service.test.ts create mode 100644 src/core/services/overlay-shortcut-handler.test.ts delete mode 100644 src/ipc/contract.ts delete mode 100644 src/ipc/main-api.ts delete mode 100644 src/ipc/renderer-api.ts delete mode 100644 src/modules/jimaku/module.ts delete mode 100644 src/modules/runtime-options/module.ts delete mode 100644 src/modules/subsync/module.ts diff --git a/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md b/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md index b7983f0..835ae34 100644 --- a/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md +++ b/backlog/tasks/task-1 - Refactor-runtime-services-per-plan.md.md @@ -4,11 +4,12 @@ title: Refactor runtime services per plan.md status: Done assignee: [] created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:50' +updated_date: '2026-02-11 03:35' labels: [] dependencies: [] references: - plan.md +ordinal: 1000 --- ## Description diff --git a/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md b/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md index 3b7a866..eca139f 100644 --- a/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md +++ b/backlog/tasks/task-1.1 - Phase-1-Remove-thin-wrapper-runtime-services.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 18:56' +updated_date: '2026-02-11 03:35' labels: [] dependencies: [] references: @@ -13,6 +13,7 @@ references: - src/main.ts - src/core/services/index.ts parent_task_id: TASK-1 +ordinal: 11000 --- ## Description diff --git a/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md b/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md index a65b18b..a707607 100644 --- a/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md +++ b/backlog/tasks/task-1.2 - Phase-2-Merge-DI-adapter-runtime-services-into-target-services.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:00' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.1 @@ -16,6 +16,7 @@ references: - src/core/services/tokenizer-service.ts - src/core/services/app-lifecycle-deps-runtime-service.ts parent_task_id: TASK-1 +ordinal: 9000 --- ## Description diff --git a/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md b/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md index ac6a42e..af54b3d 100644 --- a/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md +++ b/backlog/tasks/task-1.3 - Phase-3-Consolidate-related-service-modules.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:17' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.2 @@ -17,6 +17,7 @@ references: - src/core/services/numeric-shortcut-session-service.ts - src/core/services/app-ready-runtime-service.ts parent_task_id: TASK-1 +ordinal: 5000 --- ## Description diff --git a/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md b/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md index 9738c95..d610434 100644 --- a/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md +++ b/backlog/tasks/task-1.4 - Phase-4-Fix-runtime-bugs-and-naming-code-quality-issues.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:50' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.3 @@ -15,6 +15,7 @@ references: - src/core/services/overlay-visibility-service.ts - src/core/services/tokenizer-deps-runtime-service.ts parent_task_id: TASK-1 +ordinal: 2000 --- ## Description diff --git a/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md b/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md index 3386eb8..c5bd2e5 100644 --- a/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md +++ b/backlog/tasks/task-1.5 - Phase-5-Add-critical-behavior-tests-for-untested-services.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:36' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.4 @@ -16,6 +16,7 @@ references: - src/core/services/tokenizer-service.ts - src/core/services/cli-command-service.ts parent_task_id: TASK-1 +ordinal: 4000 --- ## Description diff --git a/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md b/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md index 4e723aa..108936d 100644 --- a/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md +++ b/backlog/tasks/task-1.6 - Phase-6-Optional-Reorganize-services-by-domain-directories.md @@ -4,13 +4,14 @@ title: 'Phase 6 (Optional): Reorganize services by domain directories' status: Done assignee: [] created_date: '2026-02-10 18:46' -updated_date: '2026-02-10 19:41' +updated_date: '2026-02-11 03:35' labels: [] dependencies: - TASK-1.5 references: - plan.md parent_task_id: TASK-1 +ordinal: 3000 --- ## Description diff --git a/backlog/tasks/task-2 - Post-refactor-follow-ups-from-investigation.md.md b/backlog/tasks/task-2 - Post-refactor-follow-ups-from-investigation.md.md new file mode 100644 index 0000000..6a937a4 --- /dev/null +++ b/backlog/tasks/task-2 - Post-refactor-follow-ups-from-investigation.md.md @@ -0,0 +1,53 @@ +--- +id: TASK-2 +title: Post-refactor follow-ups from investigation.md +status: Done +assignee: + - codex +created_date: '2026-02-10 18:56' +updated_date: '2026-02-11 03:35' +labels: [] +dependencies: + - TASK-1 +references: + - investigation.md + - docs/refactor-main-checklist.md +ordinal: 13000 +--- + +## Description + + +Execute the remaining follow-up work identified in investigation.md: remove unused scaffolding, add tests for high-risk consolidated services, and run manual smoke validation in a desktop MPV session. + + +## Acceptance Criteria + +- [x] #1 Follow-up subtasks are created with explicit scope and dependencies. +- [x] #2 Unused architectural scaffolding and abandoned IPC abstraction files are removed or explicitly retained with documented rationale. +- [x] #3 Dedicated tests are added for higher-risk consolidated services (`overlay-shortcut-handler`, `mining-service`, `anki-jimaku-service`). +- [x] #4 Manual smoke checks for overlay rendering, mining flow, and field-grouping interaction are executed and results documented. + + +## Implementation Plan + + +1. Create scoped subtasks for each recommendation in investigation.md and sequence them by risk and execution constraints. +2. Remove dead scaffolding files and any now-unneeded exports/imports; verify build/tests remain green. +3. Add focused behavior tests for the three higher-risk consolidated services. +4. Run and document desktop smoke validation in an MPV-enabled environment. + + +## Implementation Notes + + +Completed: +- Created TASK-2.1 through TASK-2.5 from `investigation.md` recommendations. +- Finished TASK-2.1: removed unused scaffolding in `src/core/`, `src/modules/`, `src/ipc/` and cleaned internal-only service barrel export. +- Finished TASK-2.2: added dedicated tests for `overlay-shortcut-handler.ts`. +- Finished TASK-2.3: added dedicated tests for `mining-service.ts`. +- Finished TASK-2.4: added dedicated tests for `anki-jimaku-service.ts`. + +Remaining: +- TASK-2.5: desktop smoke validation with MPV session + diff --git a/backlog/tasks/task-2.1 - Remove-unused-scaffolding-and-clean-exports.md b/backlog/tasks/task-2.1 - Remove-unused-scaffolding-and-clean-exports.md new file mode 100644 index 0000000..7ff0794 --- /dev/null +++ b/backlog/tasks/task-2.1 - Remove-unused-scaffolding-and-clean-exports.md @@ -0,0 +1,59 @@ +--- +id: TASK-2.1 +title: Remove unused scaffolding and clean exports +status: Done +assignee: + - codex +created_date: '2026-02-10 18:56' +updated_date: '2026-02-11 03:35' +labels: [] +dependencies: + - TASK-1 +references: + - investigation.md + - src/core/action-bus.ts + - src/core/actions.ts + - src/core/app-context.ts + - src/core/module-registry.ts + - src/core/module.ts + - src/modules/ + - src/ipc/ + - src/core/services/index.ts +parent_task_id: TASK-2 +ordinal: 10000 +--- + +## Description + + +Remove unused module-architecture scaffolding and IPC abstraction files identified as dead code, and clean service barrel exports that are not needed outside service internals. + + +## Acceptance Criteria + +- [x] #1 Files under `src/core/{action-bus.ts,actions.ts,app-context.ts,module-registry.ts,module.ts}` are removed if unreferenced. +- [x] #2 Unused `src/modules/` and `src/ipc/` scaffolding files are removed if unreferenced. +- [x] #3 `src/core/services/index.ts` no longer exports symbols that are only consumed internally (`isGlobalShortcutRegisteredSafe`). +- [x] #4 Build and core tests pass after cleanup. + + +## Implementation Plan + + +1. Verify all candidate files are truly unreferenced in runtime/test paths. +2. Delete dead scaffolding files and folders. +3. Remove unnecessary service barrel exports and fix any import fallout. +4. Run `pnpm run build` and `pnpm run test:core`. + + +## Implementation Notes + + +Removed unused scaffolding files from `src/core/`, `src/modules/`, and `src/ipc/` that were unreferenced by runtime code. + +Updated `src/core/services/index.ts` to stop re-exporting `isGlobalShortcutRegisteredSafe`, which is only used internally by service files. + +Verification: +- `pnpm run build` passed +- `pnpm run test:core` passed (18/18) + diff --git a/backlog/tasks/task-2.2 - Add-tests-for-overlay-shortcut-handler-service.md b/backlog/tasks/task-2.2 - Add-tests-for-overlay-shortcut-handler-service.md new file mode 100644 index 0000000..13a8fdc --- /dev/null +++ b/backlog/tasks/task-2.2 - Add-tests-for-overlay-shortcut-handler-service.md @@ -0,0 +1,46 @@ +--- +id: TASK-2.2 +title: Add tests for overlay shortcut handler service +status: Done +assignee: + - codex +created_date: '2026-02-10 18:56' +updated_date: '2026-02-11 03:35' +labels: [] +dependencies: + - TASK-2.1 +references: + - investigation.md + - src/core/services/overlay-shortcut-handler.ts +parent_task_id: TASK-2 +ordinal: 8000 +--- + +## Description + + +Add dedicated tests for `overlay-shortcut-handler.ts`, covering shortcut runtime handlers, fallback behavior, and key edge/error paths. + + +## Acceptance Criteria + +- [x] #1 Shortcut registration/unregistration handler behavior is covered. +- [x] #2 Fallback handling paths are covered for valid and invalid input. +- [x] #3 Error and guard behavior is covered for missing dependencies/state. +- [x] #4 `pnpm run test:core` remains green. + + +## Implementation Notes + + +Added `src/core/services/overlay-shortcut-handler.test.ts` with coverage for: +- runtime handler dispatch for sync and async actions +- async error propagation to OSD/log handling +- local fallback action matching, including timeout forwarding +- `allowWhenRegistered` behavior for secondary subtitle toggle +- no-match fallback return behavior + +Updated `package.json` `test:core` to include `dist/core/services/overlay-shortcut-handler.test.js`. + +Verification: `pnpm run test:core` passed (19/19 at completion of this ticket). + diff --git a/backlog/tasks/task-2.3 - Add-tests-for-mining-service.md b/backlog/tasks/task-2.3 - Add-tests-for-mining-service.md new file mode 100644 index 0000000..dafe779 --- /dev/null +++ b/backlog/tasks/task-2.3 - Add-tests-for-mining-service.md @@ -0,0 +1,45 @@ +--- +id: TASK-2.3 +title: Add tests for mining service +status: Done +assignee: + - codex +created_date: '2026-02-10 18:56' +updated_date: '2026-02-11 03:35' +labels: [] +dependencies: + - TASK-2.1 +references: + - investigation.md + - src/core/services/mining-service.ts +parent_task_id: TASK-2 +ordinal: 7000 +--- + +## Description + + +Add dedicated behavior tests for `mining-service.ts` covering sentence/card mining orchestration and error boundaries. + + +## Acceptance Criteria + +- [x] #1 Happy-path behavior is covered for mining entry points. +- [x] #2 Guard/early-return behavior is covered for missing runtime state. +- [x] #3 Error paths are covered with expected logging/OSD behavior. +- [x] #4 `pnpm run test:core` remains green. + + +## Implementation Notes + + +Added `src/core/services/mining-service.test.ts` with focused coverage for: +- `copyCurrentSubtitleService` guard and success behavior +- `mineSentenceCardService` integration/connection guards and success path +- `handleMultiCopyDigitService` history-copy behavior with truncation messaging +- `handleMineSentenceDigitService` async error catch and OSD/log propagation + +Updated `package.json` `test:core` to include `dist/core/services/mining-service.test.js`. + +Verification: `pnpm run test:core` passed (20/20 after adding mining tests). + diff --git a/backlog/tasks/task-2.4 - Add-tests-for-anki-jimaku-service.md b/backlog/tasks/task-2.4 - Add-tests-for-anki-jimaku-service.md new file mode 100644 index 0000000..5fcf819 --- /dev/null +++ b/backlog/tasks/task-2.4 - Add-tests-for-anki-jimaku-service.md @@ -0,0 +1,49 @@ +--- +id: TASK-2.4 +title: Add tests for anki jimaku service +status: Done +assignee: + - codex +created_date: '2026-02-10 18:56' +updated_date: '2026-02-11 03:35' +labels: [] +dependencies: + - TASK-2.1 +references: + - investigation.md + - src/core/services/anki-jimaku-service.ts +parent_task_id: TASK-2 +ordinal: 6000 +--- + +## Description + + +Add dedicated tests for `anki-jimaku-service.ts` focusing on IPC handler registration, request dispatch, and error handling behavior. + + +## Acceptance Criteria + +- [x] #1 IPC registration behavior is validated for all channels exposed by this service. +- [x] #2 Success-path behavior for core handler flows is validated. +- [x] #3 Failure-path behavior is validated with expected error propagation. +- [x] #4 `pnpm run test:core` remains green. + + +## Implementation Notes + + +Added a lightweight registration-injection seam to `registerAnkiJimakuIpcRuntimeService` so runtime behavior can be tested without Electron IPC globals. + +Added `src/core/services/anki-jimaku-service.test.ts` with coverage for: +- runtime handler surface registration +- integration disable path and runtime-options broadcast +- subtitle history clear and field-grouping response callbacks +- merge-preview guard error and integration success delegation +- Jimaku search request mapping/result capping +- downloaded-subtitle MPV command forwarding + +Updated `package.json` `test:core` to include `dist/core/services/anki-jimaku-service.test.js`. + +Verification: `pnpm run test:core` passed (21/21). + diff --git a/backlog/tasks/task-2.5 - Perform-desktop-smoke-validation-with-mpv.md b/backlog/tasks/task-2.5 - Perform-desktop-smoke-validation-with-mpv.md new file mode 100644 index 0000000..61fcb82 --- /dev/null +++ b/backlog/tasks/task-2.5 - Perform-desktop-smoke-validation-with-mpv.md @@ -0,0 +1,58 @@ +--- +id: TASK-2.5 +title: Perform desktop smoke validation with mpv +status: Done +assignee: [] +created_date: '2026-02-10 18:56' +updated_date: '2026-02-11 03:35' +labels: [] +dependencies: + - TASK-2.2 + - TASK-2.3 + - TASK-2.4 +references: + - investigation.md + - docs/refactor-main-checklist.md +parent_task_id: TASK-2 +ordinal: 12000 +--- + +## Description + + +Execute manual desktop smoke checks in an MPV-enabled environment to validate overlay rendering and key user workflows not fully covered by automated tests. + + +## Acceptance Criteria + +- [x] #1 Overlay rendering and visibility toggling are verified in a real desktop session. +- [x] #2 Card mining flow is verified end-to-end. +- [x] #3 Field-grouping interaction is verified end-to-end. +- [x] #4 Results and any follow-up defects are documented in task notes. + + +## Implementation Notes + + +Smoke run executed on 2026-02-10 with real Electron launch (outside sandbox) after unsetting `ELECTRON_RUN_AS_NODE=1` in command context. + +Commands executed: +- `electron . --help` +- `electron . --start` +- `electron . --toggle-visible-overlay` +- `electron . --toggle-invisible-overlay` +- `electron . --mine-sentence` +- `electron . --trigger-field-grouping` +- `electron . --open-runtime-options` +- `electron . --stop` + +Observed runtime evidence from app logs: +- CLI help output rendered with expected flags. +- App started and connected to MPV after reconnect attempts. +- Mining flow executed and produced `Created sentence card: ...`, plus media upload logs. +- Tracker/runtime loop started (`hyprland` tracker connected) and app stopped cleanly. + +Follow-up/constraints: +- Overlay *visual rendering* and visibility correctness are not directly observable from terminal logs alone and still require direct desktop visual confirmation. +- Field-grouping trigger command was sent, but explicit end-state confirmation in UI still needs manual verification. + diff --git a/backlog/tasks/task-3 - move-invisible-subtitles.md b/backlog/tasks/task-3 - move-invisible-subtitles.md new file mode 100644 index 0000000..528cd29 --- /dev/null +++ b/backlog/tasks/task-3 - move-invisible-subtitles.md @@ -0,0 +1,28 @@ +--- +id: TASK-3 +title: move invisible subtitles +status: Done +assignee: + - codex +created_date: '2026-02-11 03:34' +updated_date: '2026-02-11 04:28' +labels: [] +dependencies: [] +ordinal: 1000 +--- + +## Description + + +Add keybinding that will toggle edit mode on the invisible subtitles allowing for fine-grained control over positioning. use arrow keys and vim hjkl for motion and enter/ctrl+s to save and esc to cancel + + +## Implementation Notes + + +- Implemented invisible subtitle position edit mode toggle with movement/save/cancel controls. +- Added persistence for invisible subtitle offsets (`invisibleOffsetXPx`, `invisibleOffsetYPx`) alongside existing `yPercent` subtitle position state. +- Updated edit mode visuals to highlight invisible subtitle text using the same styling as debug visualization. +- Removed the edit-mode dashed bounding box. +- Updated top HUD instruction text to reference arrow keys only (while keeping `hjkl` movement support). + diff --git a/package.json b/package.json index 189f749..dee4887 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "test:config": "pnpm run build && node --test dist/config/config.test.js", - "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js", + "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js", "test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", "generate:config-example": "pnpm run build && node dist/generate-config-example.js", "start": "pnpm run build && electron . --start", diff --git a/src/core/action-bus.ts b/src/core/action-bus.ts deleted file mode 100644 index 92e7fbe..0000000 --- a/src/core/action-bus.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type ActionWithType = { type: string }; - -export type ActionHandler = ( - action: TAction, -) => void | Promise; - -export class ActionBus { - private handlers = new Map>(); - - register(type: TAction["type"], handler: ActionHandler): void { - this.handlers.set(type, handler); - } - - async dispatch(action: TAction): Promise { - const handler = this.handlers.get(action.type); - if (!handler) { - throw new Error(`No handler registered for action: ${action.type}`); - } - await handler(action); - } -} diff --git a/src/core/actions.ts b/src/core/actions.ts deleted file mode 100644 index aa4d61a..0000000 --- a/src/core/actions.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type AppAction = - | { type: "overlay.toggleVisible" } - | { type: "overlay.toggleInvisible" } - | { type: "overlay.setVisible"; visible: boolean } - | { type: "overlay.setInvisibleVisible"; visible: boolean } - | { type: "overlay.openSettings" } - | { type: "subtitle.copyCurrent" } - | { type: "subtitle.copyMultiplePrompt"; timeoutMs: number } - | { type: "anki.mineSentence" } - | { type: "anki.mineSentenceMultiplePrompt"; timeoutMs: number } - | { type: "anki.updateLastCardFromClipboard" } - | { type: "anki.markAudioCard" } - | { type: "kiku.triggerFieldGrouping" } - | { type: "subsync.triggerFromConfig" } - | { type: "secondarySub.toggleMode" } - | { type: "runtimeOptions.openPalette" }; diff --git a/src/core/app-context.ts b/src/core/app-context.ts deleted file mode 100644 index e907fe7..0000000 --- a/src/core/app-context.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - AnkiConnectConfig, - JimakuApiResponse, - JimakuDownloadQuery, - JimakuDownloadResult, - JimakuEntry, - JimakuFileEntry, - JimakuFilesQuery, - JimakuMediaInfo, - JimakuSearchQuery, - RuntimeOptionState, - SubsyncManualRunRequest, - SubsyncMode, - SubsyncResult, -} from "../types"; - -export interface RuntimeOptionsModuleContext { - getAnkiConfig: () => AnkiConnectConfig; - applyAnkiPatch: (patch: Partial) => void; - onOptionsChanged: (options: RuntimeOptionState[]) => void; -} - -export interface AppContext { - runtimeOptions?: RuntimeOptionsModuleContext; - jimaku?: { - getMediaInfo: () => JimakuMediaInfo; - searchEntries: ( - query: JimakuSearchQuery, - ) => Promise>; - listFiles: ( - query: JimakuFilesQuery, - ) => Promise>; - downloadFile: ( - query: JimakuDownloadQuery, - ) => Promise; - }; - subsync?: { - getDefaultMode: () => SubsyncMode; - openManualPicker: () => Promise; - runAuto: () => Promise; - runManual: (request: SubsyncManualRunRequest) => Promise; - showOsd: (message: string) => void; - runWithSpinner: (task: () => Promise, label?: string) => Promise; - }; -} diff --git a/src/core/module-registry.ts b/src/core/module-registry.ts deleted file mode 100644 index 72795ad..0000000 --- a/src/core/module-registry.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SubminerModule } from "./module"; - -export class ModuleRegistry { - private readonly modules: SubminerModule[] = []; - - register(module: SubminerModule): void { - if (this.modules.some((existing) => existing.id === module.id)) { - throw new Error(`Module already registered: ${module.id}`); - } - this.modules.push(module); - } - - async initAll(context: TContext): Promise { - for (const module of this.modules) { - if (module.init) { - await module.init(context); - } - } - } - - async startAll(): Promise { - for (const module of this.modules) { - if (module.start) { - await module.start(); - } - } - } - - async stopAll(): Promise { - for (const module of [...this.modules].reverse()) { - if (module.stop) { - await module.stop(); - } - } - } -} diff --git a/src/core/module.ts b/src/core/module.ts deleted file mode 100644 index 0e69a10..0000000 --- a/src/core/module.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SubminerModule { - id: string; - init?: (context: TContext) => void | Promise; - start?: () => void | Promise; - stop?: () => void | Promise; -} diff --git a/src/core/services/anki-jimaku-service.test.ts b/src/core/services/anki-jimaku-service.test.ts new file mode 100644 index 0000000..53a361a --- /dev/null +++ b/src/core/services/anki-jimaku-service.test.ts @@ -0,0 +1,228 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + AnkiJimakuIpcRuntimeOptions, + registerAnkiJimakuIpcRuntimeService, +} from "./anki-jimaku-service"; + +interface RuntimeHarness { + options: AnkiJimakuIpcRuntimeOptions; + registered: Record unknown>; + state: { + ankiIntegration: unknown; + fieldGroupingResolver: ((choice: unknown) => void) | null; + patches: boolean[]; + broadcasts: number; + fetchCalls: Array<{ endpoint: string; query?: Record }>; + sentCommands: Array<{ command: string[] }>; + }; +} + +function createHarness(): RuntimeHarness { + const state = { + ankiIntegration: null as unknown, + fieldGroupingResolver: null as ((choice: unknown) => void) | null, + patches: [] as boolean[], + broadcasts: 0, + fetchCalls: [] as Array<{ endpoint: string; query?: Record }>, + sentCommands: [] as Array<{ command: string[] }>, + }; + + const options: AnkiJimakuIpcRuntimeOptions = { + patchAnkiConnectEnabled: (enabled) => { + state.patches.push(enabled); + }, + getResolvedConfig: () => ({}), + getRuntimeOptionsManager: () => null, + getSubtitleTimingTracker: () => null, + getMpvClient: () => ({ + connected: true, + send: (payload) => { + state.sentCommands.push(payload); + }, + }), + getAnkiIntegration: () => state.ankiIntegration as never, + setAnkiIntegration: (integration) => { + state.ankiIntegration = integration; + }, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + broadcastRuntimeOptionsChanged: () => { + state.broadcasts += 1; + }, + getFieldGroupingResolver: () => state.fieldGroupingResolver as never, + setFieldGroupingResolver: (resolver) => { + state.fieldGroupingResolver = resolver as never; + }, + parseMediaInfo: () => ({ + title: "video", + confidence: "high", + rawTitle: "video", + filename: "video.mkv", + season: null, + episode: null, + }), + getCurrentMediaPath: () => "/tmp/video.mkv", + jimakuFetchJson: async (endpoint, query) => { + state.fetchCalls.push({ endpoint, query: query as Record }); + return { + ok: true, + data: [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 3, name: "c" }, + ] as never, + }; + }, + getJimakuMaxEntryResults: () => 2, + getJimakuLanguagePreference: () => "ja", + resolveJimakuApiKey: async () => "token", + isRemoteMediaPath: () => false, + downloadToFile: async (url, destPath) => ({ + ok: true, + path: `${destPath}:${url}`, + }), + }; + + let registered: Record unknown> = {}; + registerAnkiJimakuIpcRuntimeService( + options, + (deps) => { + registered = deps as unknown as Record unknown>; + }, + ); + + return { options, registered, state }; +} + +test("registerAnkiJimakuIpcRuntimeService provides full handler surface", () => { + const { registered } = createHarness(); + const expected = [ + "setAnkiConnectEnabled", + "clearAnkiHistory", + "respondFieldGrouping", + "buildKikuMergePreview", + "getJimakuMediaInfo", + "searchJimakuEntries", + "listJimakuFiles", + "resolveJimakuApiKey", + "getCurrentMediaPath", + "isRemoteMediaPath", + "downloadToFile", + "onDownloadedSubtitle", + ]; + + for (const key of expected) { + assert.equal(typeof registered[key], "function", `missing handler: ${key}`); + } +}); + +test("setAnkiConnectEnabled disables active integration and broadcasts changes", () => { + const { registered, state } = createHarness(); + let destroyed = 0; + state.ankiIntegration = { + destroy: () => { + destroyed += 1; + }, + }; + + registered.setAnkiConnectEnabled(false); + + assert.deepEqual(state.patches, [false]); + assert.equal(destroyed, 1); + assert.equal(state.ankiIntegration, null); + assert.equal(state.broadcasts, 1); +}); + +test("clearAnkiHistory and respondFieldGrouping execute runtime callbacks", () => { + const { registered, state, options } = createHarness(); + let cleaned = 0; + let resolvedChoice: unknown = null; + state.fieldGroupingResolver = (choice) => { + resolvedChoice = choice; + }; + + const originalGetTracker = options.getSubtitleTimingTracker; + options.getSubtitleTimingTracker = () => + ({ cleanup: () => { + cleaned += 1; + } }) as never; + + const choice = { + keepNoteId: 10, + deleteNoteId: 11, + deleteDuplicate: true, + cancelled: false, + }; + registered.clearAnkiHistory(); + registered.respondFieldGrouping(choice); + + options.getSubtitleTimingTracker = originalGetTracker; + + assert.equal(cleaned, 1); + assert.deepEqual(resolvedChoice, choice); + assert.equal(state.fieldGroupingResolver, null); +}); + +test("buildKikuMergePreview returns guard error when integration is missing", async () => { + const { registered } = createHarness(); + + const result = await registered.buildKikuMergePreview({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + }); + + assert.deepEqual(result, { + ok: false, + error: "AnkiConnect integration not enabled", + }); +}); + +test("buildKikuMergePreview delegates to integration when available", async () => { + const { registered, state } = createHarness(); + const calls: unknown[] = []; + state.ankiIntegration = { + buildFieldGroupingPreview: async ( + keepNoteId: number, + deleteNoteId: number, + deleteDuplicate: boolean, + ) => { + calls.push([keepNoteId, deleteNoteId, deleteDuplicate]); + return { ok: true }; + }, + }; + + const result = await registered.buildKikuMergePreview({ + keepNoteId: 3, + deleteNoteId: 4, + deleteDuplicate: true, + }); + + assert.deepEqual(calls, [[3, 4, true]]); + assert.deepEqual(result, { ok: true }); +}); + +test("searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv", async () => { + const { registered, state } = createHarness(); + + const searchResult = await registered.searchJimakuEntries({ query: "test" }); + assert.deepEqual(state.fetchCalls, [ + { + endpoint: "/api/entries/search", + query: { anime: true, query: "test" }, + }, + ]); + assert.equal((searchResult as { ok: boolean }).ok, true); + assert.equal((searchResult as { data: unknown[] }).data.length, 2); + + registered.onDownloadedSubtitle("/tmp/subtitle.ass"); + assert.deepEqual(state.sentCommands, [ + { command: ["sub-add", "/tmp/subtitle.ass", "select"] }, + ]); +}); diff --git a/src/core/services/anki-jimaku-service.ts b/src/core/services/anki-jimaku-service.ts index 819a7c2..db31912 100644 --- a/src/core/services/anki-jimaku-service.ts +++ b/src/core/services/anki-jimaku-service.ts @@ -59,8 +59,9 @@ export interface AnkiJimakuIpcRuntimeOptions { export function registerAnkiJimakuIpcRuntimeService( options: AnkiJimakuIpcRuntimeOptions, + registerHandlers: typeof registerAnkiJimakuIpcHandlers = registerAnkiJimakuIpcHandlers, ): void { - registerAnkiJimakuIpcHandlers({ + registerHandlers({ setAnkiConnectEnabled: (enabled) => { options.patchAnkiConnectEnabled(enabled); const config = options.getResolvedConfig(); diff --git a/src/core/services/index.ts b/src/core/services/index.ts index abb8e17..72c3236 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -2,7 +2,7 @@ export { TexthookerService } from "./texthooker-service"; export { hasMpvWebsocketPlugin, SubtitleWebSocketService } from "./subtitle-ws-service"; export { registerGlobalShortcutsService } from "./shortcut-service"; export { createIpcDepsRuntimeService, registerIpcHandlersService } from "./ipc-service"; -export { isGlobalShortcutRegisteredSafe, shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service"; +export { shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service"; export { refreshOverlayShortcutsRuntimeService, registerOverlayShortcutsService, diff --git a/src/core/services/mining-service.test.ts b/src/core/services/mining-service.test.ts new file mode 100644 index 0000000..ef0c427 --- /dev/null +++ b/src/core/services/mining-service.test.ts @@ -0,0 +1,168 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + copyCurrentSubtitleService, + handleMineSentenceDigitService, + handleMultiCopyDigitService, + mineSentenceCardService, +} from "./mining-service"; + +test("copyCurrentSubtitleService reports tracker and subtitle guards", () => { + const osd: string[] = []; + const copied: string[] = []; + + copyCurrentSubtitleService({ + subtitleTimingTracker: null, + writeClipboardText: (text) => copied.push(text), + showMpvOsd: (text) => osd.push(text), + }); + assert.equal(osd.at(-1), "Subtitle tracker not available"); + + copyCurrentSubtitleService({ + subtitleTimingTracker: { + getRecentBlocks: () => [], + getCurrentSubtitle: () => null, + findTiming: () => null, + }, + writeClipboardText: (text) => copied.push(text), + showMpvOsd: (text) => osd.push(text), + }); + assert.equal(osd.at(-1), "No current subtitle"); + assert.deepEqual(copied, []); +}); + +test("copyCurrentSubtitleService copies current subtitle text", () => { + const osd: string[] = []; + const copied: string[] = []; + + copyCurrentSubtitleService({ + subtitleTimingTracker: { + getRecentBlocks: () => [], + getCurrentSubtitle: () => "hello world", + findTiming: () => null, + }, + writeClipboardText: (text) => copied.push(text), + showMpvOsd: (text) => osd.push(text), + }); + + assert.deepEqual(copied, ["hello world"]); + assert.equal(osd.at(-1), "Copied subtitle"); +}); + +test("mineSentenceCardService handles missing integration and disconnected mpv", async () => { + const osd: string[] = []; + + await mineSentenceCardService({ + ankiIntegration: null, + mpvClient: null, + showMpvOsd: (text) => osd.push(text), + }); + assert.equal(osd.at(-1), "AnkiConnect integration not enabled"); + + await mineSentenceCardService({ + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async () => {}, + }, + mpvClient: { + connected: false, + currentSubText: "line", + currentSubStart: 1, + currentSubEnd: 2, + }, + showMpvOsd: (text) => osd.push(text), + }); + + assert.equal(osd.at(-1), "MPV not connected"); +}); + +test("mineSentenceCardService creates sentence card from mpv subtitle state", async () => { + const created: Array<{ + sentence: string; + startTime: number; + endTime: number; + secondarySub?: string; + }> = []; + + await mineSentenceCardService({ + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async (sentence, startTime, endTime, secondarySub) => { + created.push({ sentence, startTime, endTime, secondarySub }); + }, + }, + mpvClient: { + connected: true, + currentSubText: "subtitle line", + currentSubStart: 10, + currentSubEnd: 12, + currentSecondarySubText: "secondary line", + }, + showMpvOsd: () => {}, + }); + + assert.deepEqual(created, [ + { + sentence: "subtitle line", + startTime: 10, + endTime: 12, + secondarySub: "secondary line", + }, + ]); +}); + +test("handleMultiCopyDigitService copies available history and reports truncation", () => { + const osd: string[] = []; + const copied: string[] = []; + + handleMultiCopyDigitService(5, { + subtitleTimingTracker: { + getRecentBlocks: (count) => ["a", "b"].slice(0, count), + getCurrentSubtitle: () => null, + findTiming: () => null, + }, + writeClipboardText: (text) => copied.push(text), + showMpvOsd: (text) => osd.push(text), + }); + + assert.deepEqual(copied, ["a\n\nb"]); + assert.equal(osd.at(-1), "Only 2 lines available, copied 2"); +}); + +test("handleMineSentenceDigitService reports async create failures", async () => { + const osd: string[] = []; + const logs: Array<{ message: string; err: unknown }> = []; + + handleMineSentenceDigitService(2, { + subtitleTimingTracker: { + getRecentBlocks: () => ["one", "two"], + getCurrentSubtitle: () => null, + findTiming: (text) => + text === "one" + ? { startTime: 1, endTime: 3 } + : { startTime: 4, endTime: 7 }, + }, + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async () => { + throw new Error("mine boom"); + }, + }, + getCurrentSecondarySubText: () => "sub2", + showMpvOsd: (text) => osd.push(text), + logError: (message, err) => logs.push({ message, err }), + }); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(logs.length, 1); + assert.equal(logs[0]?.message, "mineSentenceMultiple failed:"); + assert.equal((logs[0]?.err as Error).message, "mine boom"); + assert.ok(osd.some((entry) => entry.includes("Mine sentence failed: mine boom"))); +}); diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts new file mode 100644 index 0000000..f80c994 --- /dev/null +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -0,0 +1,256 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ConfiguredShortcuts } from "../utils/shortcut-config"; +import { + createOverlayShortcutRuntimeHandlers, + OverlayShortcutRuntimeDeps, + runOverlayShortcutLocalFallback, +} from "./overlay-shortcut-handler"; + +function makeShortcuts( + overrides: Partial = {}, +): ConfiguredShortcuts { + return { + toggleVisibleOverlayGlobal: null, + toggleInvisibleOverlayGlobal: null, + copySubtitle: null, + copySubtitleMultiple: null, + updateLastCardFromClipboard: null, + triggerFieldGrouping: null, + triggerSubsync: null, + mineSentence: null, + mineSentenceMultiple: null, + multiCopyTimeoutMs: 2500, + toggleSecondarySub: null, + markAudioCard: null, + openRuntimeOptions: null, + openJimaku: null, + ...overrides, + }; +} + +function createDeps(overrides: Partial = {}) { + const calls: string[] = []; + const osd: string[] = []; + const deps: OverlayShortcutRuntimeDeps = { + showMpvOsd: (text) => { + osd.push(text); + }, + openRuntimeOptions: () => { + calls.push("openRuntimeOptions"); + }, + openJimaku: () => { + calls.push("openJimaku"); + }, + markAudioCard: async () => { + calls.push("markAudioCard"); + }, + copySubtitleMultiple: (timeoutMs) => { + calls.push(`copySubtitleMultiple:${timeoutMs}`); + }, + copySubtitle: () => { + calls.push("copySubtitle"); + }, + toggleSecondarySub: () => { + calls.push("toggleSecondarySub"); + }, + updateLastCardFromClipboard: async () => { + calls.push("updateLastCardFromClipboard"); + }, + triggerFieldGrouping: async () => { + calls.push("triggerFieldGrouping"); + }, + triggerSubsync: async () => { + calls.push("triggerSubsync"); + }, + mineSentence: async () => { + calls.push("mineSentence"); + }, + mineSentenceMultiple: (timeoutMs) => { + calls.push(`mineSentenceMultiple:${timeoutMs}`); + }, + ...overrides, + }; + + return { deps, calls, osd }; +} + +test("createOverlayShortcutRuntimeHandlers dispatches sync and async handlers", async () => { + const { deps, calls } = createDeps(); + const { overlayHandlers, fallbackHandlers } = + createOverlayShortcutRuntimeHandlers(deps); + + overlayHandlers.copySubtitle(); + overlayHandlers.copySubtitleMultiple(1111); + overlayHandlers.toggleSecondarySub(); + overlayHandlers.openRuntimeOptions(); + overlayHandlers.openJimaku(); + overlayHandlers.mineSentenceMultiple(2222); + overlayHandlers.updateLastCardFromClipboard(); + fallbackHandlers.mineSentence(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(calls, [ + "copySubtitle", + "copySubtitleMultiple:1111", + "toggleSecondarySub", + "openRuntimeOptions", + "openJimaku", + "mineSentenceMultiple:2222", + "updateLastCardFromClipboard", + "mineSentence", + ]); +}); + +test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", async () => { + const logs: unknown[][] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + logs.push(args); + }; + + try { + const { deps, osd } = createDeps({ + markAudioCard: async () => { + throw new Error("audio boom"); + }, + }); + const { overlayHandlers } = createOverlayShortcutRuntimeHandlers(deps); + + overlayHandlers.markAudioCard(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(logs.length, 1); + assert.equal(logs[0]?.[0], "markLastCardAsAudioCard failed:"); + assert.ok(osd.some((entry) => entry.includes("Audio card failed: audio boom"))); + } finally { + console.error = originalError; + } +}); + +test("runOverlayShortcutLocalFallback dispatches matching actions with timeout", () => { + const handled: string[] = []; + const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; + const shortcuts = makeShortcuts({ + copySubtitleMultiple: "Ctrl+M", + multiCopyTimeoutMs: 4321, + }); + + const result = runOverlayShortcutLocalFallback( + {} as Electron.Input, + shortcuts, + (_input, accelerator, allowWhenRegistered) => { + matched.push({ + accelerator, + allowWhenRegistered: allowWhenRegistered === true, + }); + return accelerator === "Ctrl+M"; + }, + { + openRuntimeOptions: () => handled.push("openRuntimeOptions"), + openJimaku: () => handled.push("openJimaku"), + markAudioCard: () => handled.push("markAudioCard"), + copySubtitleMultiple: (timeoutMs) => + handled.push(`copySubtitleMultiple:${timeoutMs}`), + copySubtitle: () => handled.push("copySubtitle"), + toggleSecondarySub: () => handled.push("toggleSecondarySub"), + updateLastCardFromClipboard: () => + handled.push("updateLastCardFromClipboard"), + triggerFieldGrouping: () => handled.push("triggerFieldGrouping"), + triggerSubsync: () => handled.push("triggerSubsync"), + mineSentence: () => handled.push("mineSentence"), + mineSentenceMultiple: (timeoutMs) => + handled.push(`mineSentenceMultiple:${timeoutMs}`), + }, + ); + + assert.equal(result, true); + assert.deepEqual(handled, ["copySubtitleMultiple:4321"]); + assert.deepEqual(matched, [{ accelerator: "Ctrl+M", allowWhenRegistered: false }]); +}); + +test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle", () => { + const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; + const shortcuts = makeShortcuts({ + toggleSecondarySub: "Ctrl+2", + }); + + const result = runOverlayShortcutLocalFallback( + {} as Electron.Input, + shortcuts, + (_input, accelerator, allowWhenRegistered) => { + matched.push({ + accelerator, + allowWhenRegistered: allowWhenRegistered === true, + }); + return accelerator === "Ctrl+2"; + }, + { + openRuntimeOptions: () => {}, + openJimaku: () => {}, + markAudioCard: () => {}, + copySubtitleMultiple: () => {}, + copySubtitle: () => {}, + toggleSecondarySub: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + }, + ); + + assert.equal(result, true); + assert.deepEqual(matched, [{ accelerator: "Ctrl+2", allowWhenRegistered: true }]); +}); + +test("runOverlayShortcutLocalFallback returns false when no action matches", () => { + const shortcuts = makeShortcuts({ + copySubtitle: "Ctrl+C", + }); + let called = false; + + const result = runOverlayShortcutLocalFallback( + {} as Electron.Input, + shortcuts, + () => false, + { + openRuntimeOptions: () => { + called = true; + }, + openJimaku: () => { + called = true; + }, + markAudioCard: () => { + called = true; + }, + copySubtitleMultiple: () => { + called = true; + }, + copySubtitle: () => { + called = true; + }, + toggleSecondarySub: () => { + called = true; + }, + updateLastCardFromClipboard: () => { + called = true; + }, + triggerFieldGrouping: () => { + called = true; + }, + triggerSubsync: () => { + called = true; + }, + mineSentence: () => { + called = true; + }, + mineSentenceMultiple: () => { + called = true; + }, + }, + ); + + assert.equal(result, false); + assert.equal(called, false); +}); diff --git a/src/core/services/subtitle-position-service.ts b/src/core/services/subtitle-position-service.ts index c09b333..98f3cd7 100644 --- a/src/core/services/subtitle-position-service.ts +++ b/src/core/services/subtitle-position-service.ts @@ -80,7 +80,20 @@ export function loadSubtitlePositionService(options: { typeof parsed.yPercent === "number" && Number.isFinite(parsed.yPercent) ) { - return { yPercent: parsed.yPercent }; + const position: SubtitlePosition = { yPercent: parsed.yPercent }; + if ( + typeof parsed.invisibleOffsetXPx === "number" && + Number.isFinite(parsed.invisibleOffsetXPx) + ) { + position.invisibleOffsetXPx = parsed.invisibleOffsetXPx; + } + if ( + typeof parsed.invisibleOffsetYPx === "number" && + Number.isFinite(parsed.invisibleOffsetYPx) + ) { + position.invisibleOffsetYPx = parsed.invisibleOffsetYPx; + } + return position; } return options.fallbackPosition; } catch (err) { diff --git a/src/ipc/contract.ts b/src/ipc/contract.ts deleted file mode 100644 index 8e2a403..0000000 --- a/src/ipc/contract.ts +++ /dev/null @@ -1,61 +0,0 @@ -export const IPC_CHANNELS = { - rendererToMainInvoke: { - getOverlayVisibility: "get-overlay-visibility", - getVisibleOverlayVisibility: "get-visible-overlay-visibility", - getInvisibleOverlayVisibility: "get-invisible-overlay-visibility", - getCurrentSubtitle: "get-current-subtitle", - getCurrentSubtitleAss: "get-current-subtitle-ass", - getMpvSubtitleRenderMetrics: "get-mpv-subtitle-render-metrics", - getSubtitlePosition: "get-subtitle-position", - getSubtitleStyle: "get-subtitle-style", - getMecabStatus: "get-mecab-status", - getKeybindings: "get-keybindings", - getSecondarySubMode: "get-secondary-sub-mode", - getCurrentSecondarySub: "get-current-secondary-sub", - runSubsyncManual: "subsync:run-manual", - getAnkiConnectStatus: "get-anki-connect-status", - runtimeOptionsGet: "runtime-options:get", - runtimeOptionsSet: "runtime-options:set", - runtimeOptionsCycle: "runtime-options:cycle", - kikuBuildMergePreview: "kiku:build-merge-preview", - jimakuGetMediaInfo: "jimaku:get-media-info", - jimakuSearchEntries: "jimaku:search-entries", - jimakuListFiles: "jimaku:list-files", - jimakuDownloadFile: "jimaku:download-file", - }, - rendererToMainSend: { - setIgnoreMouseEvents: "set-ignore-mouse-events", - overlayModalClosed: "overlay:modal-closed", - openYomitanSettings: "open-yomitan-settings", - quitApp: "quit-app", - toggleDevTools: "toggle-dev-tools", - toggleOverlay: "toggle-overlay", - saveSubtitlePosition: "save-subtitle-position", - setMecabEnabled: "set-mecab-enabled", - mpvCommand: "mpv-command", - setAnkiConnectEnabled: "set-anki-connect-enabled", - clearAnkiConnectHistory: "clear-anki-connect-history", - kikuFieldGroupingRespond: "kiku:field-grouping-respond", - }, - mainToRendererEvent: { - subtitleSet: "subtitle:set", - mpvSubVisibility: "mpv:subVisibility", - subtitlePositionSet: "subtitle-position:set", - mpvSubtitleRenderMetricsSet: "mpv-subtitle-render-metrics:set", - subtitleAssSet: "subtitle-ass:set", - overlayDebugVisualizationSet: "overlay-debug-visualization:set", - secondarySubtitleSet: "secondary-subtitle:set", - secondarySubtitleMode: "secondary-subtitle:mode", - subsyncOpenManual: "subsync:open-manual", - kikuFieldGroupingRequest: "kiku:field-grouping-request", - runtimeOptionsChanged: "runtime-options:changed", - runtimeOptionsOpen: "runtime-options:open", - }, -} as const; - -export type RendererToMainInvokeChannel = - (typeof IPC_CHANNELS.rendererToMainInvoke)[keyof typeof IPC_CHANNELS.rendererToMainInvoke]; -export type RendererToMainSendChannel = - (typeof IPC_CHANNELS.rendererToMainSend)[keyof typeof IPC_CHANNELS.rendererToMainSend]; -export type MainToRendererEventChannel = - (typeof IPC_CHANNELS.mainToRendererEvent)[keyof typeof IPC_CHANNELS.mainToRendererEvent]; diff --git a/src/ipc/main-api.ts b/src/ipc/main-api.ts deleted file mode 100644 index f400148..0000000 --- a/src/ipc/main-api.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ipcMain, IpcMainEvent } from "electron"; -import { - RendererToMainInvokeChannel, - RendererToMainSendChannel, -} from "./contract"; - -export function onRendererSend( - channel: RendererToMainSendChannel, - listener: (event: IpcMainEvent, ...args: any[]) => void, -): void { - ipcMain.on(channel, listener); -} - -export function handleRendererInvoke( - channel: RendererToMainInvokeChannel, - handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => unknown, -): void { - ipcMain.handle(channel, handler); -} diff --git a/src/ipc/renderer-api.ts b/src/ipc/renderer-api.ts deleted file mode 100644 index 3bfea1d..0000000 --- a/src/ipc/renderer-api.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ipcRenderer, IpcRendererEvent } from "electron"; -import { - MainToRendererEventChannel, - RendererToMainInvokeChannel, - RendererToMainSendChannel, -} from "./contract"; - -export function invokeFromRenderer( - channel: RendererToMainInvokeChannel, - ...args: unknown[] -): Promise { - return ipcRenderer.invoke(channel, ...args) as Promise; -} - -export function sendFromRenderer( - channel: RendererToMainSendChannel, - ...args: unknown[] -): void { - ipcRenderer.send(channel, ...args); -} - -export function onMainEvent( - channel: MainToRendererEventChannel, - listener: (event: IpcRendererEvent, ...args: unknown[]) => void, -): void { - ipcRenderer.on(channel, listener); -} diff --git a/src/modules/jimaku/module.ts b/src/modules/jimaku/module.ts deleted file mode 100644 index 662da69..0000000 --- a/src/modules/jimaku/module.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { AppContext } from "../../core/app-context"; -import { SubminerModule } from "../../core/module"; -import { - JimakuApiResponse, - JimakuDownloadQuery, - JimakuDownloadResult, - JimakuEntry, - JimakuFileEntry, - JimakuFilesQuery, - JimakuMediaInfo, - JimakuSearchQuery, -} from "../../types"; - -export class JimakuModule implements SubminerModule { - readonly id = "jimaku"; - private context: AppContext["jimaku"] | undefined; - - init(context: AppContext): void { - if (!context.jimaku) { - throw new Error("Jimaku context is missing"); - } - this.context = context.jimaku; - } - - getMediaInfo(): JimakuMediaInfo { - if (!this.context) { - return { - title: "", - season: null, - episode: null, - confidence: "low", - filename: "", - rawTitle: "", - }; - } - return this.context.getMediaInfo(); - } - - searchEntries( - query: JimakuSearchQuery, - ): Promise> { - if (!this.context) { - return Promise.resolve({ - ok: false, - error: { error: "Jimaku module not initialized" }, - }); - } - return this.context.searchEntries(query); - } - - listFiles( - query: JimakuFilesQuery, - ): Promise> { - if (!this.context) { - return Promise.resolve({ - ok: false, - error: { error: "Jimaku module not initialized" }, - }); - } - return this.context.listFiles(query); - } - - downloadFile(query: JimakuDownloadQuery): Promise { - if (!this.context) { - return Promise.resolve({ - ok: false, - error: { error: "Jimaku module not initialized" }, - }); - } - return this.context.downloadFile(query); - } -} diff --git a/src/modules/runtime-options/module.ts b/src/modules/runtime-options/module.ts deleted file mode 100644 index 2bbf1ab..0000000 --- a/src/modules/runtime-options/module.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { AppContext } from "../../core/app-context"; -import { SubminerModule } from "../../core/module"; -import { RuntimeOptionsManager } from "../../runtime-options"; -import { - AnkiConnectConfig, - RuntimeOptionApplyResult, - RuntimeOptionId, - RuntimeOptionState, - RuntimeOptionValue, -} from "../../types"; - -export class RuntimeOptionsModule implements SubminerModule { - readonly id = "runtime-options"; - private manager: RuntimeOptionsManager | null = null; - - init(context: AppContext): void { - if (!context.runtimeOptions) { - throw new Error("Runtime options context is missing"); - } - - this.manager = new RuntimeOptionsManager( - context.runtimeOptions.getAnkiConfig, - { - applyAnkiPatch: context.runtimeOptions.applyAnkiPatch, - onOptionsChanged: context.runtimeOptions.onOptionsChanged, - }, - ); - } - - listOptions(): RuntimeOptionState[] { - return this.manager ? this.manager.listOptions() : []; - } - - getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined { - return this.manager?.getOptionValue(id); - } - - setOptionValue( - id: RuntimeOptionId, - value: RuntimeOptionValue, - ): RuntimeOptionApplyResult { - if (!this.manager) { - return { ok: false, error: "Runtime options manager unavailable" }; - } - return this.manager.setOptionValue(id, value); - } - - cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult { - if (!this.manager) { - return { ok: false, error: "Runtime options manager unavailable" }; - } - return this.manager.cycleOption(id, direction); - } - - getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig { - if (!this.manager) { - return baseConfig ? JSON.parse(JSON.stringify(baseConfig)) : {}; - } - return this.manager.getEffectiveAnkiConnectConfig(baseConfig); - } -} diff --git a/src/modules/subsync/module.ts b/src/modules/subsync/module.ts deleted file mode 100644 index 250b65a..0000000 --- a/src/modules/subsync/module.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { AppContext } from "../../core/app-context"; -import { SubminerModule } from "../../core/module"; -import { SubsyncManualRunRequest, SubsyncResult } from "../../types"; - -export class SubsyncModule implements SubminerModule { - readonly id = "subsync"; - private inProgress = false; - private context: AppContext["subsync"] | undefined; - - init(context: AppContext): void { - if (!context.subsync) { - throw new Error("Subsync context is missing"); - } - this.context = context.subsync; - } - - isInProgress(): boolean { - return this.inProgress; - } - - async triggerFromConfig(): Promise { - if (!this.context) { - throw new Error("Subsync module not initialized"); - } - - if (this.inProgress) { - this.context.showOsd("Subsync already running"); - return; - } - - try { - if (this.context.getDefaultMode() === "manual") { - await this.context.openManualPicker(); - this.context.showOsd("Subsync: choose engine and source"); - return; - } - - this.inProgress = true; - const result = await this.context.runWithSpinner( - () => this.context!.runAuto(), - "Subsync: syncing", - ); - this.context.showOsd(result.message); - } catch (error) { - this.context.showOsd(`Subsync failed: ${(error as Error).message}`); - } finally { - this.inProgress = false; - } - } - - async runManual(request: SubsyncManualRunRequest): Promise { - if (!this.context) { - return { ok: false, message: "Subsync module not initialized" }; - } - - if (this.inProgress) { - const busy = "Subsync already running"; - this.context.showOsd(busy); - return { ok: false, message: busy }; - } - - try { - this.inProgress = true; - const result = await this.context.runWithSpinner( - () => this.context!.runManual(request), - "Subsync: syncing", - ); - this.context.showOsd(result.message); - return result; - } catch (error) { - const message = `Subsync failed: ${(error as Error).message}`; - this.context.showOsd(message); - return { ok: false, message }; - } finally { - this.inProgress = false; - } - } -} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 76f2a76..aba04fe 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -64,6 +64,8 @@ interface Keybinding { interface SubtitlePosition { yPercent: number; + invisibleOffsetXPx?: number; + invisibleOffsetYPx?: number; } type SecondarySubMode = "hidden" | "visible" | "hover"; @@ -342,12 +344,16 @@ const isMacOSPlatform = // Linux passthrough forwarding is not reliable for this overlay; keep pointer // routing local so hover lookup, drag-reposition, and key handling remain usable. const shouldToggleMouseIgnore = !isLinuxPlatform; +const INVISIBLE_POSITION_EDIT_TOGGLE_CODE = "KeyP"; +const INVISIBLE_POSITION_STEP_PX = 1; +const INVISIBLE_POSITION_STEP_FAST_PX = 4; let isOverSubtitle = false; let isDragging = false; let dragStartY = 0; let startYPercent = 0; let currentYPercent: number | null = null; +let persistedSubtitlePosition: SubtitlePosition = { yPercent: 10 }; let jimakuModalOpen = false; let jimakuEntries: JimakuEntry[] = []; let jimakuFiles: JimakuFileEntry[] = []; @@ -393,6 +399,15 @@ const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = { let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = { ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, }; +let invisiblePositionEditMode = false; +let invisiblePositionEditStartX = 0; +let invisiblePositionEditStartY = 0; +let invisibleSubtitleOffsetXPx = 0; +let invisibleSubtitleOffsetYPx = 0; +let invisibleLayoutBaseLeftPx = 0; +let invisibleLayoutBaseBottomPx: number | null = null; +let invisibleLayoutBaseTopPx: number | null = null; +let invisiblePositionEditHud: HTMLDivElement | null = null; let currentInvisibleSubtitleLineCount = 1; let lastHoverSelectionKey = ""; let lastHoverSelectionNode: Text | null = null; @@ -554,7 +569,8 @@ function handleMouseLeave(): void { !jimakuModalOpen && !kikuModalOpen && !runtimeOptionsModalOpen && - !subsyncModalOpen + !subsyncModalOpen && + !invisiblePositionEditMode ) { overlay.classList.remove("interactive"); if (shouldToggleMouseIgnore) { @@ -591,10 +607,52 @@ function applyYPercent(yPercent: number): void { subtitleContainer.style.marginBottom = `${marginBottom}px`; } +function updatePersistedSubtitlePosition(position: SubtitlePosition | null): void { + const nextYPercent = + position && typeof position.yPercent === "number" && Number.isFinite(position.yPercent) + ? position.yPercent + : persistedSubtitlePosition.yPercent; + const nextXOffset = + position && typeof position.invisibleOffsetXPx === "number" && Number.isFinite(position.invisibleOffsetXPx) + ? position.invisibleOffsetXPx + : 0; + const nextYOffset = + position && typeof position.invisibleOffsetYPx === "number" && Number.isFinite(position.invisibleOffsetYPx) + ? position.invisibleOffsetYPx + : 0; + persistedSubtitlePosition = { + yPercent: nextYPercent, + invisibleOffsetXPx: nextXOffset, + invisibleOffsetYPx: nextYOffset, + }; +} + +function persistSubtitlePositionPatch(patch: Partial): void { + const nextPosition: SubtitlePosition = { + yPercent: + typeof patch.yPercent === "number" && Number.isFinite(patch.yPercent) + ? patch.yPercent + : persistedSubtitlePosition.yPercent, + invisibleOffsetXPx: + typeof patch.invisibleOffsetXPx === "number" && + Number.isFinite(patch.invisibleOffsetXPx) + ? patch.invisibleOffsetXPx + : persistedSubtitlePosition.invisibleOffsetXPx ?? 0, + invisibleOffsetYPx: + typeof patch.invisibleOffsetYPx === "number" && + Number.isFinite(patch.invisibleOffsetYPx) + ? patch.invisibleOffsetYPx + : persistedSubtitlePosition.invisibleOffsetYPx ?? 0, + }; + persistedSubtitlePosition = nextPosition; + window.electronAPI.saveSubtitlePosition(nextPosition); +} + function applyStoredSubtitlePosition( position: SubtitlePosition | null, source: string, ): void { + updatePersistedSubtitlePosition(position); if (position && position.yPercent !== undefined) { applyYPercent(position.yPercent); console.log( @@ -612,6 +670,66 @@ function applyStoredSubtitlePosition( } } +function applyInvisibleSubtitleOffsetPosition(): void { + const nextLeft = invisibleLayoutBaseLeftPx + invisibleSubtitleOffsetXPx; + subtitleContainer.style.left = `${nextLeft}px`; + + if (invisibleLayoutBaseBottomPx !== null) { + subtitleContainer.style.bottom = `${Math.max(0, invisibleLayoutBaseBottomPx + invisibleSubtitleOffsetYPx)}px`; + subtitleContainer.style.top = ""; + return; + } + + if (invisibleLayoutBaseTopPx !== null) { + subtitleContainer.style.top = `${Math.max(0, invisibleLayoutBaseTopPx - invisibleSubtitleOffsetYPx)}px`; + subtitleContainer.style.bottom = ""; + } +} + +function updateInvisiblePositionEditHud(): void { + if (!invisiblePositionEditHud) return; + invisiblePositionEditHud.textContent = + `Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(invisibleSubtitleOffsetXPx)} y:${Math.round(invisibleSubtitleOffsetYPx)}`; +} + +function setInvisiblePositionEditMode(enabled: boolean): void { + if (!isInvisibleLayer) return; + if (invisiblePositionEditMode === enabled) return; + invisiblePositionEditMode = enabled; + document.body.classList.toggle("invisible-position-edit", enabled); + if (enabled) { + invisiblePositionEditStartX = invisibleSubtitleOffsetXPx; + invisiblePositionEditStartY = invisibleSubtitleOffsetYPx; + overlay.classList.add("interactive"); + if (shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(false); + } + } else if (!isOverSubtitle && !isAnySettingsModalOpen()) { + overlay.classList.remove("interactive"); + if (shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); + } + } + updateInvisiblePositionEditHud(); +} + +function applyInvisibleStoredSubtitlePosition( + position: SubtitlePosition | null, + source: string, +): void { + updatePersistedSubtitlePosition(position); + invisibleSubtitleOffsetXPx = persistedSubtitlePosition.invisibleOffsetXPx ?? 0; + invisibleSubtitleOffsetYPx = persistedSubtitlePosition.invisibleOffsetYPx ?? 0; + applyInvisibleSubtitleOffsetPosition(); + console.log( + "[invisible-overlay] Applied subtitle offset from", + source, + `${invisibleSubtitleOffsetXPx}px`, + `${invisibleSubtitleOffsetYPx}px`, + ); + updateInvisiblePositionEditHud(); +} + function applySubtitleFontSize(fontSize: number): void { const clampedSize = Math.max(10, fontSize); subtitleRoot.style.fontSize = `${clampedSize}px`; @@ -944,6 +1062,15 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( } } } + invisibleLayoutBaseLeftPx = parseFloat(subtitleContainer.style.left) || 0; + invisibleLayoutBaseBottomPx = Number.isFinite(parseFloat(subtitleContainer.style.bottom)) + ? parseFloat(subtitleContainer.style.bottom) + : null; + invisibleLayoutBaseTopPx = Number.isFinite(parseFloat(subtitleContainer.style.top)) + ? parseFloat(subtitleContainer.style.top) + : null; + applyInvisibleSubtitleOffsetPosition(); + updateInvisiblePositionEditHud(); console.log("[invisible-overlay] Applied mpv subtitle render metrics from", source); } @@ -1860,7 +1987,7 @@ function setupDragging(): void { subtitleContainer.style.cursor = ""; const yPercent = getCurrentYPercent(); - window.electronAPI.saveSubtitlePosition({ yPercent }); + persistSubtitlePositionPatch({ yPercent }); } }); @@ -2017,6 +2144,91 @@ function keyEventToString(e: KeyboardEvent): string { return parts.join("+"); } +function isInvisiblePositionToggleShortcut(e: KeyboardEvent): boolean { + return ( + e.code === INVISIBLE_POSITION_EDIT_TOGGLE_CODE && + !e.altKey && + e.shiftKey && + (e.ctrlKey || e.metaKey) + ); +} + +function saveInvisiblePositionEdit(): void { + persistSubtitlePositionPatch({ + invisibleOffsetXPx: invisibleSubtitleOffsetXPx, + invisibleOffsetYPx: invisibleSubtitleOffsetYPx, + }); + setInvisiblePositionEditMode(false); +} + +function cancelInvisiblePositionEdit(): void { + invisibleSubtitleOffsetXPx = invisiblePositionEditStartX; + invisibleSubtitleOffsetYPx = invisiblePositionEditStartY; + applyInvisibleSubtitleOffsetPosition(); + setInvisiblePositionEditMode(false); +} + +function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean { + if (!isInvisibleLayer) return false; + + if (isInvisiblePositionToggleShortcut(e)) { + e.preventDefault(); + if (invisiblePositionEditMode) { + cancelInvisiblePositionEdit(); + } else { + setInvisiblePositionEditMode(true); + } + return true; + } + + if (!invisiblePositionEditMode) return false; + + const step = e.shiftKey ? INVISIBLE_POSITION_STEP_FAST_PX : INVISIBLE_POSITION_STEP_PX; + + if (e.key === "Escape") { + e.preventDefault(); + cancelInvisiblePositionEdit(); + return true; + } + + if (e.key === "Enter" || ((e.ctrlKey || e.metaKey) && e.code === "KeyS")) { + e.preventDefault(); + saveInvisiblePositionEdit(); + return true; + } + + if ( + e.key === "ArrowUp" || + e.key === "ArrowDown" || + e.key === "ArrowLeft" || + e.key === "ArrowRight" || + e.key === "h" || + e.key === "j" || + e.key === "k" || + e.key === "l" || + e.key === "H" || + e.key === "J" || + e.key === "K" || + e.key === "L" + ) { + e.preventDefault(); + if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") { + invisibleSubtitleOffsetYPx += step; + } else if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") { + invisibleSubtitleOffsetYPx -= step; + } else if (e.key === "ArrowLeft" || e.key === "h" || e.key === "H") { + invisibleSubtitleOffsetXPx -= step; + } else if (e.key === "ArrowRight" || e.key === "l" || e.key === "L") { + invisibleSubtitleOffsetXPx += step; + } + applyInvisibleSubtitleOffsetPosition(); + updateInvisiblePositionEditHud(); + return true; + } + + return true; +} + let keybindingsMap = new Map(); type ChordAction = @@ -2073,6 +2285,7 @@ async function setupMpvInputForwarding(): Promise { document.addEventListener("keydown", (e: KeyboardEvent) => { const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); if (yomitanPopup) return; + if (handleInvisiblePositionEditKeydown(e)) return; if (runtimeOptionsModalOpen) { handleRuntimeOptionsKeydown(e); @@ -2202,6 +2415,16 @@ function setupSelectionObserver(): void { }); } +function setupInvisiblePositionEditHud(): void { + if (!isInvisibleLayer) return; + const hud = document.createElement("div"); + hud.id = "invisiblePositionEditHud"; + hud.className = "invisible-position-edit-hud"; + overlay.appendChild(hud); + invisiblePositionEditHud = hud; + updateInvisiblePositionEditHud(); +} + function setupYomitanObserver(): void { const observer = new MutationObserver((mutations: MutationRecord[]) => { for (const mutation of mutations) { @@ -2340,13 +2563,15 @@ async function init(): Promise { renderSubtitle(data); }); - if (!isInvisibleLayer) { - window.electronAPI.onSubtitlePosition( - (position: SubtitlePosition | null) => { + window.electronAPI.onSubtitlePosition( + (position: SubtitlePosition | null) => { + if (isInvisibleLayer) { + applyInvisibleStoredSubtitlePosition(position, "media-change"); + } else { applyStoredSubtitlePosition(position, "media-change"); - }, - ); - } + } + }, + ); if (isInvisibleLayer) { window.electronAPI.onMpvSubtitleRenderMetrics( @@ -2380,6 +2605,7 @@ async function init(): Promise { hoverTarget.addEventListener("mouseenter", handleMouseEnter); hoverTarget.addEventListener("mouseleave", handleMouseLeave); setupInvisibleHoverSelection(); + setupInvisiblePositionEditHud(); secondarySubContainer.addEventListener("mouseenter", handleMouseEnter); secondarySubContainer.addEventListener("mouseleave", handleMouseLeave); @@ -2503,6 +2729,8 @@ async function init(): Promise { setupResizeHandler(); if (isInvisibleLayer) { + const position = await window.electronAPI.getSubtitlePosition(); + applyInvisibleStoredSubtitlePosition(position, "startup"); const metrics = await window.electronAPI.getMpvSubtitleRenderMetrics(); applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "startup"); } else { diff --git a/src/renderer/style.css b/src/renderer/style.css index e03b344..ecf4458 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -350,6 +350,39 @@ body.layer-invisible.debug-invisible-visualization #subtitleRoot .c { text-shadow: none !important; } +.invisible-position-edit-hud { + position: absolute; + top: 14px; + left: 50%; + transform: translateX(-50%); + z-index: 30; + max-width: min(90vw, 1100px); + padding: 6px 10px; + border-radius: 8px; + font-size: 12px; + line-height: 1.35; + color: rgba(255, 255, 255, 0.95); + background: rgba(22, 24, 36, 0.88); + border: 1px solid rgba(130, 150, 255, 0.55); + pointer-events: none; + opacity: 0; + transition: opacity 120ms ease; +} + +body.layer-invisible.invisible-position-edit .invisible-position-edit-hud { + opacity: 1; +} + +body.layer-invisible.invisible-position-edit #subtitleRoot, +body.layer-invisible.invisible-position-edit #subtitleRoot .word, +body.layer-invisible.invisible-position-edit #subtitleRoot .c { + color: #ed8796 !important; + -webkit-text-fill-color: #ed8796 !important; + -webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important; + paint-order: stroke fill !important; + text-shadow: none !important; +} + #secondarySubContainer { position: absolute; top: 40px; diff --git a/src/types.ts b/src/types.ts index f5a8499..49d225b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,6 +60,8 @@ export interface WindowGeometry { export interface SubtitlePosition { yPercent: number; + invisibleOffsetXPx?: number; + invisibleOffsetYPx?: number; } export interface SubtitleStyle { From 08d44499d325851af889793711be0bfda5a924e1 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 19:55:33 -0800 Subject: [PATCH 15/74] Document invisible subtitle position edit mode --- README.md | 1 + config.example.jsonc | 2 ++ docs/configuration.md | 7 +++++++ docs/public/config.example.jsonc | 2 ++ docs/usage.md | 4 ++++ src/config/definitions.ts | 4 ++++ 6 files changed, 20 insertions(+) diff --git a/README.md b/README.md index 61796bb..4687789 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ An all-in-one sentence mining overlay for MPV with AnkiConnect and dictionary (Y - Integrated texthooker-ui server for use with Yomitan - Integrated websocket server (if [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is not found) to send lines to the texthooker - AnkiConnect integration for automatic card creation with media (audio/image) +- Invisible subtitle position edit mode (`Ctrl/Cmd+Shift+P`, arrow keys to adjust, `Enter`/`Ctrl+S` save, `Esc` cancel) ## Demo diff --git a/config.example.jsonc b/config.example.jsonc index 2e7ebc2..686d99b 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -122,6 +122,8 @@ // ========================================== // Invisible Overlay // Startup behavior for the invisible interactive subtitle mining layer. + // Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel. + // This edit-mode shortcut is fixed and is not currently configurable. // ========================================== "invisibleOverlay": { "startupVisibility": "platform-default" diff --git a/docs/configuration.md b/docs/configuration.md index d2815b7..fe17875 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -278,6 +278,13 @@ SubMiner includes a second subtitle mining layer that can be visually invisible 2. `"visible"`: always shown on startup. 3. `"hidden"`: always hidden on startup. +Invisible subtitle positioning can be adjusted directly in the invisible layer: + +- `Ctrl/Cmd+Shift+P` toggles position edit mode. +- Use arrow keys to move the invisible subtitle text. +- Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel. +- This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`). + ### Jimaku diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index 2e7ebc2..686d99b 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -122,6 +122,8 @@ // ========================================== // Invisible Overlay // Startup behavior for the invisible interactive subtitle mining layer. + // Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel. + // This edit-mode shortcut is fixed and is not currently configurable. // ========================================== "invisibleOverlay": { "startupVisibility": "platform-default" diff --git a/docs/usage.md b/docs/usage.md index cd63da5..700d699 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -112,6 +112,10 @@ Notes: | `Ctrl+W` | Quit mpv | | `Right-click` | Toggle MPV pause (outside subtitle area) | | `Right-click + drag` | Move subtitle position (on subtitle) | +| `Ctrl/Cmd+Shift+P` | Toggle invisible subtitle position edit mode | +| `Arrow keys` | Move invisible subtitles while edit mode is active | +| `Enter` / `Ctrl+S` | Save invisible subtitle position in edit mode | +| `Esc` | Cancel invisible subtitle position edit mode | These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization. diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 9ddb819..641c96d 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -389,6 +389,10 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ description: [ "Startup behavior for the invisible interactive subtitle mining layer.", ], + notes: [ + "Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.", + "This edit-mode shortcut is fixed and is not currently configurable.", + ], key: "invisibleOverlay", }, { From 7a83fc51681c87d3f71f556d5ed288a761b8866e Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 22:27:28 -0800 Subject: [PATCH 16/74] update docs --- docs/.vitepress/config.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index cd7a9a3..deea54f 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -2,7 +2,7 @@ const repositoryName = process.env.GITHUB_REPOSITORY?.split('/')[1]; const base = process.env.GITHUB_ACTIONS && repositoryName ? `/${repositoryName}/` : '/'; export default { - title: 'SubMiner', + title: 'SubMiner Docs', description: 'Documentation for SubMiner', base, appearance: 'dark', @@ -15,7 +15,11 @@ export default { }, }, themeConfig: { - logo: '/assets/SubMiner.png', + logo: { + light: '/assets/SubMiner.png', + dark: '/assets/SubMiner.png', + }, + siteTitle: 'SubMiner Docs', nav: [ { text: 'Docs', link: '/' }, { text: 'Installation', link: '/installation' }, From 9f0f8a2ce9264c2b72ca2dd7e1e0431323d32805 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 23:23:25 -0800 Subject: [PATCH 17/74] Set SubMiner mpv launch defaults and doc naming consistency --- docs/configuration.md | 27 +++++---------------------- docs/installation.md | 28 ++++++++++++++-------------- docs/usage.md | 34 ++++++++++++++++++++++------------ subminer | 9 +++++++++ 4 files changed, 50 insertions(+), 48 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index fe17875..ffe404b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,16 +2,16 @@ Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset). For backward compatibility, SubMiner also reads existing configs from lowercase `subminer` directories. -### Configuration File +## Configuration File See [config.example.jsonc](/config.example.jsonc) for a comprehensive example configuration file with all available options, default values, and detailed comments. Only include the options you want to customize in your config file. Generate a fresh default config from the centralized config registry: ```bash -subminer.AppImage --generate-config -subminer.AppImage --generate-config --config-path /tmp/subminer.jsonc -subminer.AppImage --generate-config --backup-overwrite +SubMiner.AppImage --generate-config +SubMiner.AppImage --generate-config --config-path /tmp/subminer.jsonc +SubMiner.AppImage --generate-config --backup-overwrite ``` - `--generate-config` writes a default JSONC config template. @@ -22,7 +22,6 @@ subminer.AppImage --generate-config --backup-overwrite Invalid config values are handled with warn-and-fallback behavior: SubMiner logs the bad key/value and continues with the default for that option. - ### Configuration Options Overview The configuration file includes several main sections: @@ -43,7 +42,6 @@ The configuration file includes several main sections: - [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server - [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback - ### AnkiConnect Enable automatic Anki card creation and updates with media generation: @@ -170,8 +168,6 @@ Kiku extends Lapis with **field grouping** — when a duplicate card is detected Open demo in a new tab - - | Mode | Behavior | | ---------- | -------------------------------------------------------------------------------------------------------------------------- | | `auto` | Automatically merges the new card's content into the original; duplicate deletion is controlled by `deleteDuplicateInAuto` | @@ -211,7 +207,6 @@ To copy multiple lines (current + previous): These shortcuts are only active when the overlay window is visible. They are automatically disabled when the overlay is hidden to avoid interfering with normal system clipboard operations. - ### Auto-Start Overlay Control whether the overlay automatically becomes visible when it connects to mpv: @@ -226,7 +221,7 @@ Control whether the overlay automatically becomes visible when it connects to mp | -------------------- | --------------- | ------------------------------------------------------ | | `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) | -The mpv plugin now controls startup per layer via `auto_start_visible_overlay` and `auto_start_invisible_overlay` in `subminer.conf` (`platform-default` for invisible means hidden on Linux, visible on macOS/Windows). +The mpv plugin controls startup per layer via `auto_start_visible_overlay` and `auto_start_invisible_overlay` in `subminer.conf` (`platform-default` for invisible means hidden on Linux, visible on macOS/Windows). ### Visible Overlay Subtitle Binding @@ -242,7 +237,6 @@ Control whether toggling the visible overlay also toggles MPV subtitle visibilit | --------------------------------------------- | --------------- | ----------- | | `bind_visible_overlay_to_mpv_sub_visibility` | `true`, `false` | When `true` (default), visible overlay hides MPV primary/secondary subtitles and restores them when hidden. When `false`, visible overlay toggles do not change MPV subtitle visibility. | - ### Auto Subtitle Sync Sync the active subtitle track using `alass` (preferred) or `ffsubsync`: @@ -268,7 +262,6 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`: Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`. Customize it there, or set it to `null` to disable. - ### Invisible Overlay SubMiner includes a second subtitle mining layer that can be visually invisible while still interactive for Yomitan lookups. @@ -285,7 +278,6 @@ Invisible subtitle positioning can be adjusted directly in the invisible layer: - Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel. - This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`). - ### Jimaku Configure Jimaku API access and defaults: @@ -306,7 +298,6 @@ Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry dela Set `openBrowser` to `false` to only print the URL without opening a browser. - ### Keybindings Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv: @@ -357,7 +348,6 @@ See `config.example.jsonc` for detailed configuration options and more examples. **See `config.example.jsonc`** for more keybinding examples and configuration options. - ### Runtime Option Palette Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart. @@ -376,7 +366,6 @@ Palette controls: - `Enter`: apply selected value - `Esc`: close - ### Secondary Subtitles Display a second subtitle track (e.g., English alongside Japanese) in the overlay: @@ -407,7 +396,6 @@ See `config.example.jsonc` for detailed configuration options. **See `config.example.jsonc`** for additional secondary subtitle configuration options. - ### Shortcuts Configuration Customize or disable the overlay keyboard shortcuts: @@ -455,7 +443,6 @@ See `config.example.jsonc` for detailed configuration options. Set any shortcut to `null` to disable it. - ### Subtitle Position Set the initial vertical subtitle position (measured from the bottom of the screen): @@ -472,7 +459,6 @@ Set the initial vertical subtitle position (measured from the bottom of the scre | ---------- | --------------- | ------------------------------------------------------------------ | | `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) | - ### Subtitle Style Customize the appearance of primary and secondary subtitles: @@ -511,7 +497,6 @@ Secondary subtitle defaults: `fontSize: 24`, `fontColor: "#ffffff"`, `background **See `config.example.jsonc`** for the complete list of subtitle style configuration options. - ### Texthooker Control whether the browser opens automatically when texthooker starts: @@ -526,7 +511,6 @@ See `config.example.jsonc` for detailed configuration options. } ``` - ### WebSocket Server The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing. @@ -549,7 +533,6 @@ See `config.example.jsonc` for detailed configuration options. | `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected | | `port` | number | WebSocket server port (default: 6677) | - ### YouTube Subtitle Generation Set defaults used by the `subminer` launcher for YouTube subtitle extraction/transcription: diff --git a/docs/installation.md b/docs/installation.md index 5499ef4..d73ebae 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,4 +1,6 @@ -# Requirements +# Installation + +## Requirements ### Linux @@ -25,23 +27,21 @@ - yt-dlp (recommended for reliable YouTube playback/subtitles in mpv) - bun (required to run the `subminer` wrapper script from source/local installs) -## Installation - ### From AppImage (Recommended) -Download the latest AppImage from GitHub Releases: +Download the latest AppImage from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest): ```bash # Download and install AppImage -wget https://github.com/sudacode/subminer/releases/download/v1.0.0/subminer-1.0.0.AppImage -O ~/.local/bin/subminer.AppImage -chmod +x ~/.local/bin/subminer.AppImage +wget https://github.com/ksyasuda/SubMiner/releases/download/v0.1.0/SubMiner-0.1.0.AppImage -O ~/.local/bin/SubMiner.AppImage +chmod +x ~/.local/bin/SubMiner.AppImage # Download subminer wrapper script -wget https://github.com/sudacode/subminer/releases/download/v1.0.0/subminer -O ~/.local/bin/subminer +wget https://github.com/ksyasuda/SubMiner/releases/download/v0.1.0/subminer -O ~/.local/bin/subminer chmod +x ~/.local/bin/subminer ``` -Note: the `subminer` wrapper uses a Bun shebang (`#!/usr/bin/env bun`), so `bun` must be installed and available on `PATH`. +Note: the `subminer` wrapper uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. ### macOS Installation @@ -57,8 +57,8 @@ brew install mpv mecab mecab-ipadic Build from source: ```bash -git clone https://github.com/sudacode/subminer.git -cd subminer +git clone https://github.com/ksyasuda/SubMiner.git +cd SubMiner pnpm install cd vendor/texthooker-ui && pnpm install && pnpm build && cd ../.. pnpm run build:mac @@ -100,8 +100,8 @@ Set these GitHub Actions secrets before creating a release tag: ### From Source ```bash -git clone https://github.com/sudacode/subminer.git -cd subminer +git clone https://github.com/ksyasuda/SubMiner.git +cd SubMiner make build # Install platform artifacts @@ -227,8 +227,8 @@ The plugin auto-detects the binary location, searching: - `C:\Program Files\subminer\subminer.exe` - `C:\Program Files (x86)\subminer\subminer.exe` - `C:\subminer\subminer.exe` -- `~/.local/bin/subminer.AppImage` -- `/opt/subminer/subminer.AppImage` +- `~/.local/bin/SubMiner.AppImage` +- `/opt/SubMiner/SubMiner.AppImage` - `/usr/local/bin/subminer` - `/usr/bin/subminer` diff --git a/docs/usage.md b/docs/usage.md index 700d699..6667e82 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -31,22 +31,32 @@ subminer -p gpu-hq video.mkv # Override mpv profile subminer --yt-subgen-mode preprocess --whisper-bin /path/to/whisper-cli --whisper-model /path/to/model.bin https://youtu.be/... # Pre-generate subtitle tracks before playback # Direct AppImage control -subminer.AppImage --start --texthooker # Start overlay with texthooker -subminer.AppImage --texthooker # Launch texthooker only (no overlay window) -subminer.AppImage --stop # Stop overlay -subminer.AppImage --start --toggle # Start MPV IPC + toggle visibility -subminer.AppImage --start --toggle-invisible-overlay # Start MPV IPC + toggle invisible layer -subminer.AppImage --show-visible-overlay # Force show visible overlay -subminer.AppImage --hide-visible-overlay # Force hide visible overlay -subminer.AppImage --show-invisible-overlay # Force show invisible overlay -subminer.AppImage --hide-invisible-overlay # Force hide invisible overlay -subminer.AppImage --settings # Open Yomitan settings -subminer.AppImage --help # Show all options +SubMiner.AppImage --start --texthooker # Start overlay with texthooker +SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window) +SubMiner.AppImage --stop # Stop overlay +SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility +SubMiner.AppImage --start --toggle-invisible-overlay # Start MPV IPC + toggle invisible layer +SubMiner.AppImage --show-visible-overlay # Force show visible overlay +SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay +SubMiner.AppImage --show-invisible-overlay # Force show invisible overlay +SubMiner.AppImage --hide-invisible-overlay # Force hide invisible overlay +SubMiner.AppImage --settings # Open Yomitan settings +SubMiner.AppImage --help # Show all options ``` ### MPV Profile Example (mpv.conf) -Add a profile to `~/.config/mpv/mpv.conf`; `subminer` now launches mpv with `--profile=subminer` by default (or override with `subminer -p ...`): +`subminer` passes the following MPV options directly on launch by default: + +- `--input-ipc-server=/tmp/subminer-socket` (or your configured socket path) +- `--slang=ja,jpn,en,eng` +- `--sub-auto=fuzzy` +- `--sub-file-paths=.;subs;subtitles` +- `--sid=auto` +- `--secondary-sid=auto` +- `--secondary-sub-visibility=no` + +You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. `subminer` launches with `--profile=subminer` by default (or override with `subminer -p ...`): ```ini [subminer] diff --git a/subminer b/subminer index 41c1f99..87ea908 100755 --- a/subminer +++ b/subminer @@ -46,6 +46,14 @@ const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join( "youtube-subs", ); const DEFAULT_YOUTUBE_YTDL_FORMAT = "bestvideo*+bestaudio/best"; +const DEFAULT_MPV_SUBMINER_ARGS = [ + "--sub-auto=fuzzy", + "--sub-file-paths=.;subs;subtitles", + "--sid=auto", + "--secondary-sid=auto", + "--secondary-sub-visibility=no", + "--slang=ja,jpn,en,eng", +] as const; type LogLevel = "debug" | "info" | "warn" | "error"; type YoutubeSubgenMode = "automatic" | "preprocess" | "off"; @@ -1723,6 +1731,7 @@ function startMpv( const mpvArgs: string[] = []; if (args.profile) mpvArgs.push(`--profile=${args.profile}`); + mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); if (targetKind === "url" && isYoutubeTarget(target)) { const subtitleLangs = uniqueNormalizedLangCodes([ From 781e6dd4fa8f86597e849c5419fce9deb203b9b7 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 23:25:14 -0800 Subject: [PATCH 18/74] docs: overhaul documentation and add four new pages - Add mining-workflow.md: end-to-end sentence mining guide - Add anki-integration.md: AnkiConnect setup, field mapping, media generation, field grouping - Add mpv-plugin.md: chord keybindings, subminer.conf options, script messages - Add troubleshooting.md: common issues and solutions by category - Rewrite architecture.md to reflect current ~1,400-line main.ts and ~35 services - Expand development.md from ~25 lines to full dev guide - Fix URLs to ksyasuda/SubMiner, version to v0.1.0, AppImage naming - Update VitePress sidebar with three-group layout (Getting Started, Reference, Development) - Update navigation in index.md, README.md, docs/README.md - Remove obsolete planning artifacts (plan.md, investigation.md, comparison.md, composability.md, refactor-main-checklist.md) --- README.md | 41 ++-- comparison.md | 201 ----------------- composability.md | 324 --------------------------- docs/.vitepress/config.ts | 11 +- docs/.vitepress/theme/index.ts | 4 +- docs/README.md | 42 ++-- docs/anki-integration.md | 262 ++++++++++++++++++++++ docs/architecture.md | 186 +++++++++++----- docs/development.md | 93 +++++++- docs/index.md | 18 +- docs/mining-workflow.md | 153 +++++++++++++ docs/mpv-plugin.md | 174 +++++++++++++++ docs/refactor-main-checklist.md | 46 ---- docs/troubleshooting.md | 189 ++++++++++++++++ investigation.md | 219 ------------------- plan.md | 377 -------------------------------- 16 files changed, 1045 insertions(+), 1295 deletions(-) delete mode 100644 comparison.md delete mode 100644 composability.md create mode 100644 docs/anki-integration.md create mode 100644 docs/mining-workflow.md create mode 100644 docs/mpv-plugin.md delete mode 100644 docs/refactor-main-checklist.md create mode 100644 docs/troubleshooting.md delete mode 100644 investigation.md delete mode 100644 plan.md diff --git a/README.md b/README.md index 4687789..eafd931 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ An all-in-one sentence mining overlay for MPV with AnkiConnect and dictionary (Y - Yomitan integration for fast, on-screen lookups - Japanese text tokenization using MeCab with smart word boundary detection - Integrated texthooker-ui server for use with Yomitan -- Integrated websocket server (if [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is not found) to send lines to the texthooker +- Integrated WebSocket server (if [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is not found) to send lines to the texthooker - AnkiConnect integration for automatic card creation with media (audio/image) - Invisible subtitle position edit mode (`Ctrl/Cmd+Shift+P`, arrow keys to adjust, `Enter`/`Ctrl+S` save, `Esc` cancel) @@ -32,29 +32,31 @@ Optional but recommended: `yt-dlp`, `fzf`, `rofi`, `chafa`, `ffmpegthumbnailer`. ### Linux (AppImage) +Download the latest release from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest): + ```bash -wget https://github.com/sudacode/subminer/releases/download/v1.0.0/subminer-1.0.0.AppImage -O ~/.local/bin/subminer.AppImage -chmod +x ~/.local/bin/subminer.AppImage -wget https://github.com/sudacode/subminer/releases/download/v1.0.0/subminer -O ~/.local/bin/subminer +wget https://github.com/ksyasuda/SubMiner/releases/download/v0.1.0/SubMiner-0.1.0.AppImage -O ~/.local/bin/SubMiner.AppImage +chmod +x ~/.local/bin/SubMiner.AppImage +wget https://github.com/ksyasuda/SubMiner/releases/download/v0.1.0/subminer -O ~/.local/bin/subminer chmod +x ~/.local/bin/subminer ``` -`subminer` uses a [Bun](https://bun.com) shebang, so `bun` must be on `PATH`. +The `subminer` wrapper uses a [Bun](https://bun.sh) shebang, so `bun` must be on `PATH`. -### From source +### From Source ```bash -git clone https://github.com/sudacode/subminer.git -cd subminer +git clone https://github.com/ksyasuda/SubMiner.git +cd SubMiner make build make install ``` -For macOS app bundle / signing / permissions details, use `docs/installation.md`. +For macOS builds, signing, and platform-specific details, see [docs/installation.md](docs/installation.md). ## Quick Start -1. Copy and customize [`config.example.jsonc`](config.example.jsonc) to `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` if `XDG_CONFIG_HOME` is unset). +1. Copy and customize [`config.example.jsonc`](config.example.jsonc) to `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`). 2. Start mpv with IPC enabled: ```bash @@ -86,6 +88,7 @@ subminer -T video.mkv # disable texthooker ```bash cp plugin/subminer.lua ~/.config/mpv/scripts/ cp plugin/subminer.conf ~/.config/mpv/script-opts/ +# or: make install-plugin ``` Requires mpv IPC: `--input-ipc-server=/tmp/subminer-socket` @@ -97,18 +100,22 @@ Overlay Jimaku shortcut default: `Ctrl+Alt+J` (`shortcuts.openJimaku`). Detailed guides live in [`docs/`](docs/README.md): -- [Installation](docs/installation.md) -- [Usage](docs/usage.md) -- [Configuration](docs/configuration.md) -- [Development](docs/development.md) -- [Architecture](docs/architecture.md) (includes `OverlayManager` state ownership and deps wiring rules) +- [Installation](docs/installation.md) — Platform requirements, AppImage/macOS/source installs, mpv plugin +- [Usage](docs/usage.md) — Script vs plugin workflow, keybindings, YouTube playback +- [Mining Workflow](docs/mining-workflow.md) — End-to-end mining guide, overlay layers, card creation +- [Configuration](docs/configuration.md) — Full config reference and option details +- [Anki Integration](docs/anki-integration.md) — AnkiConnect setup, field mapping, media generation +- [MPV Plugin](docs/mpv-plugin.md) — Chord keybindings, subminer.conf options, script messages +- [Troubleshooting](docs/troubleshooting.md) — Common issues and solutions +- [Development](docs/development.md) — Building, testing, contributing +- [Architecture](docs/architecture.md) — Service-oriented design, composition model ### Third-Party Components This project includes the following third-party components: -- **[Yomitan](https://github.com/yomidevs/yomitan)** - GPL-3.0 -- **[texthooker-ui](https://github.com/Renji-XD/texthooker-ui)** - MIT +- **[Yomitan](https://github.com/yomidevs/yomitan)** — GPL-3.0 +- **[texthooker-ui](https://github.com/Renji-XD/texthooker-ui)** — MIT ### Acknowledgments diff --git a/comparison.md b/comparison.md deleted file mode 100644 index 1b38104..0000000 --- a/comparison.md +++ /dev/null @@ -1,201 +0,0 @@ -# SubMiner vs Memento: Comparative Analysis - -## Overview - -| | **SubMiner** | **Memento** | -| ------------------ | ------------------------------------- | ---------------------------------------- | -| **Language** | TypeScript (Electron) | C++ (Qt6) | -| **Video Player** | External mpv (IPC socket) | Embedded libmpv (OpenGL widget) | -| **Dictionary** | Yomitan (embedded browser extension) | Own SQLite engine (Yomichan-format dicts) | -| **Platforms** | Linux (X11/Wayland), macOS | Linux, macOS, Windows | -| **Maturity** | Early (v0.1.0) | Mature (v1.7.2, AUR/Flathub packages) | - ---- - -## Where Memento is Stronger - -### 1. Self-Contained Native Application - -Memento embeds mpv directly via libmpv, creating a seamless single-window experience. Player controls, subtitle display, and dictionary lookups all live in one cohesive application. SubMiner runs as a separate overlay process communicating with mpv via IPC, which introduces latency and compositor-specific complexity (Hyprland, Sway, X11 each need their own window-tracking backend). - -### 2. Built-In Dictionary Engine - -Memento has its own SQLite-backed dictionary engine that directly imports Yomichan zip files. No external browser extension needed. This gives Memento tighter control over the lookup UX — results appear inline, kanji cards show stroke order, pitch accent diagrams render natively. SubMiner delegates all dictionary rendering to Yomitan, so the lookup experience depends entirely on the external extension. - -### 3. Deconjugation Engine - -Memento has a full 90+ rule deconjugation engine as a fallback when MeCab isn't available. This means grammar-aware search works even without optional dependencies. SubMiner relies entirely on MeCab for morphological analysis and falls back to raw, unsegmented text if MeCab is unavailable. - -### 4. Windows Support - -Memento has fully documented Windows builds (MSYS2/MinGW), portable installs, and platform-specific troubleshooting docs. SubMiner doesn't ship Windows builds and the platform detection code is untested on Windows. - -### 5. Kanji Detail Cards - -Memento displays rich kanji detail cards with stroke order visualization, JLPT level, frequency rankings, classifications, and codepoints — all rendered natively in Qt. SubMiner delegates kanji display entirely to Yomitan. - -### 6. Packaging & Distribution - -Memento is available on AUR, Flathub, and provides pre-built binaries for all three platforms. It's an established project with a known user base and mature release process. - -### 7. OCR Support - -Memento supports optional OCR via manga-ocr for extracting text from on-screen images (useful for hardsubbed content or manga). SubMiner has no OCR capability. - -### 8. Resource Efficiency - -As a native C++/Qt application, Memento has a significantly smaller memory footprint than SubMiner's Electron-based architecture, which typically consumes 150-300MB of RAM for the Chromium runtime alone. - ---- - -## Where SubMiner is Stronger - -### 1. Anki Integration Depth - -SubMiner's Anki workflow is significantly more advanced: - -- **Automatic polling** detects new cards and auto-populates media fields (no manual trigger needed after the initial mine action) -- **Kiku field grouping** detects duplicate words across cards and merges them into grouped cards with a two-step preview modal -- **Dual note type support** (Lapis sentence cards + Kiku vocabulary cards) with independent field mappings -- **AI translation fallback** uses OpenAI-compatible APIs (OpenRouter, local models) to generate translations when secondary subtitles are unavailable - -Memento's Anki integration is capable but more traditional: manual "Add to Anki" buttons with configurable templates and no automation beyond the initial card creation. - -### 2. Subtitle Timing Intelligence - -SubMiner maintains a subtitle timing cache (200-entry LRU with 5-minute TTL) and uses fuzzy edit-distance matching to locate audio segments for media extraction. This means audio clips are precisely timed even when card creation is delayed or when the subtitle text has been slightly modified. Memento captures the current frame and audio at the moment of card creation. - -### 3. Multi-Copy & Batch Mining - -SubMiner supports multi-copy mode (`Ctrl+Shift+C` to batch-copy N recent subtitle lines) and multi-mine mode (`Ctrl+Alt+S`). This is useful for dialogue-heavy scenes where you want context spanning multiple subtitle lines in a single card. - -### 4. YouTube Subtitle Generation - -SubMiner has built-in Whisper integration for generating subtitles from YouTube videos that lack them. Three modes are available: automatic (generate on-the-fly), preprocess (generate before playback), and off. Memento uses yt-dlp for streaming but has no subtitle generation capability for videos without existing subtitles. - -### 5. Jimaku Integration - -SubMiner connects to the Jimaku anime subtitle API for searching and downloading subtitle files directly from the overlay UI. This streamlines the workflow of finding Japanese subtitles for anime. Memento has no equivalent feature. - -### 6. Subtitle Sync (Subsync) - -SubMiner integrates with alass and ffsubsync for in-session subtitle resynchronization, with a modal UI for selecting the sync engine and source audio track. Memento relies on mpv's native subtitle delay controls, which only handle constant offsets rather than non-linear drift. - -### 7. Texthooker / WebSocket Broadcasting - -SubMiner runs a texthooker HTTP server (port 5174) and a WebSocket server (port 6677), broadcasting subtitles and tokenization data to external tools in real-time. This enables integration with browser-based dictionaries, other mining tools, or custom automation workflows. Memento is fully self-contained with no external broadcast mechanism. - -### 8. AI Translation - -SubMiner can call OpenAI-compatible APIs to generate translations on-the-fly when secondary subtitles aren't available. This includes configurable system prompts, target language selection, and auto-retry with exponential backoff. Memento has no AI or machine translation capability. - -### 9. Runtime Options Modal - -SubMiner has a `Ctrl+Shift+O` palette for toggling session-only options (auto-update cards, field grouping, etc.) without editing config files or restarting. Memento requires navigating through its settings dialog for configuration changes. - -### 10. Overlay Architecture - -SubMiner's transparent overlay design means it works with any existing mpv instance — users keep their mpv configuration, plugins, shaders, and keybindings untouched. Memento requires using its own player, and while it supports mpv config files, they must be placed in Memento's separate config directory. - ---- - -## Where They're Comparable - -| Feature | SubMiner | Memento | -| ------------------------ | ------------------------------------- | ------------------------------------- | -| MeCab tokenization | Yes (optional) | Yes (optional) | -| Secondary subtitles | Yes (3 display modes) | Yes (requires mpv >= 0.35) | -| Configurable styling | CSS variables in config | Qt stylesheet + settings dialog | -| mpv config support | Via mpv directly (user's own config) | Separate Memento config directory | -| AnkiConnect protocol | Yes | Yes | -| Media capture | FFmpeg (audio + image/AVIF) | mpv screenshot + audio extraction | -| Yomichan dict compat | Via Yomitan extension | Native import of Yomichan zip files | -| Pitch accent display | Via Yomitan | Native rendering | -| Streaming (yt-dlp) | Yes | Yes | - ---- - -## Architectural Trade-offs - -### Overlay vs Integrated App - -| Aspect | SubMiner (Overlay) | Memento (Integrated) | -| --------------- | ------------------------------------------------------------ | ------------------------------------------------------- | -| UX cohesion | Separate window; requires compositor cooperation | Single window; everything in one place | -| mpv flexibility | Works with any mpv instance and config | Must use Memento's player; separate config directory | -| Latency | IPC socket introduces small delay | Direct libmpv calls; near-zero latency | -| Platform quirks | Needs per-compositor window tracking (Hyprland/Sway/X11) | Qt handles platform abstraction | - -### Electron vs Native C++/Qt - -| Aspect | SubMiner (Electron/TypeScript) | Memento (C++/Qt6) | -| ------------------ | ------------------------------------------- | ----------------------------------------- | -| Dev velocity | Rapid iteration, rich npm ecosystem | Slower development, manual memory mgmt | -| Memory footprint | ~150-300MB (Chromium runtime) | ~30-80MB typical | -| Startup time | Slower (Chromium spin-up) | Fast native startup | -| Dependency weight | Node modules + Electron binary (~200MB) | System libraries (Qt, mpv, SQLite) | -| UI flexibility | Full HTML/CSS/JS; easy to style | Qt widgets; powerful but more constrained | - -### External Yomitan vs Built-In Dictionary - -| Aspect | SubMiner (Yomitan) | Memento (Built-in) | -| ------------------ | ------------------------------------------------- | --------------------------------------------- | -| Maintenance burden | Free updates from Yomitan project | Must maintain dictionary engine in-house | -| UX control | Limited to Yomitan's popup behavior | Full control over lookup UI and rendering | -| Setup complexity | Requires Yomitan extension + dictionaries | Import dictionaries directly; no extension | -| Feature parity | Gets all Yomitan features automatically | Must implement each feature (pitch, kanji) | - ---- - -## Code Quality Comparison - -### SubMiner - -**Strengths:** -- Clean TypeScript with strict mode -- Centralized config registry with validation and example generation -- Robust error handling with exponential backoff retry logic -- Good separation of concerns (tokenization, media generation, Anki polling) - -**Weaknesses:** -- Monolithic `main.ts` at ~5,000 lines — could benefit from modularization -- Limited test coverage (only `config.test.ts` exists) -- Token merging heuristics are complex (14 helper functions) and may miss edge cases - -### Memento - -**Strengths:** -- Modular C++ architecture with clear directory structure (dict/, player/, gui/, anki/) -- Qt signal/slot pattern keeps components decoupled -- Thread-safe dictionary access via `QReadWriteLock` -- Smart pointer usage throughout; no obvious memory management issues -- Settings migration system for version upgrades - -**Weaknesses:** -- 90+ hardcoded deconjugation rules need manual maintenance for new patterns -- No visible test suite in the repository -- MpvWidget property map handles 40+ properties in a single class - ---- - -## Summary - -**Memento** is a more mature, polished, self-contained desktop application. Its strength is the integrated experience — one app that handles video playback, dictionary lookups, and Anki card creation in a single native window. It has better Windows support, a built-in dictionary engine with deconjugation fallback, kanji stroke-order cards, and OCR support. It's the safer choice for a user who wants something that works out of the box with minimal setup. - -**SubMiner** is more innovative in its Anki workflow automation, mining UX, and external integrations. Features like automatic card polling, Kiku field grouping, AI translation fallback, Whisper subtitle generation, Jimaku subtitle search, subsync integration, and WebSocket broadcasting are all capabilities Memento doesn't have. The overlay architecture also means users can keep their existing mpv setup completely untouched. - -### Key Memento Advantages to Learn From - -- Built-in deconjugation fallback (grammar awareness without MeCab) -- OCR support for non-subtitle text -- Windows platform support -- Self-contained dictionary engine (no external browser extension dependency) -- Lower resource footprint as a native application - -### Key SubMiner Advantages to Protect - -- Anki automation depth (polling, field grouping, AI translation) -- Subtitle intelligence (timing cache, Whisper generation, Jimaku, subsync) -- External tool interop (WebSocket, texthooker server) -- Overlay model (works with any mpv instance and configuration) -- Rapid feature development enabled by TypeScript/Electron diff --git a/composability.md b/composability.md deleted file mode 100644 index 57a1b38..0000000 --- a/composability.md +++ /dev/null @@ -1,324 +0,0 @@ -# SubMiner Composability and Extensibility Plan - -## Goals - -- Reduce coupling concentrated in `src/main.ts`. -- Make new features additive (new files/modules) instead of invasive (edits across many files). -- Standardize extension points for trackers, subtitle processing, IPC actions, and integrations. -- Preserve existing behavior while incrementally migrating architecture. - -## Current Constraints (From Existing Code) - -- `src/main.ts` is the effective integration bus (~5k LOC) and mixes lifecycle, IPC, shortcuts, mpv integration, overlay control, and feature flows. -- Input surfaces are fragmented (CLI args, global shortcuts, renderer IPC) and often route through separate logic paths. -- Some extension points already exist (`src/window-trackers/*`, centralized config definitions in `src/config/definitions.ts`) but still use hardcoded selection/wiring. -- Type contracts are duplicated between main/preload/renderer in places, which increases drift risk. - -## Target Architecture - -### 1. Composition Root + Services - -- Keep `src/main.ts` as bootstrap only. -- Move behavior into focused services: - - `src/core/app-orchestrator.ts` - - `src/core/services/overlay-service.ts` - - `src/core/services/mpv-service.ts` - - `src/core/services/shortcut-service.ts` - - `src/core/services/ipc-service.ts` - - `src/core/services/subtitle-service.ts` - -### 2. Module System - -- Introduce module contract: - - `src/core/module.ts` - - `src/core/module-registry.ts` -- Built-in features become modules: - - anki module - - jimaku module - - runtime options module - - texthooker/websocket module - - subsync module - -### 3. Action Bus - -- Add typed action dispatch (single command path for CLI/shortcut/IPC): - - `src/core/actions.ts` (action type map) - - `src/core/action-bus.ts` (register/dispatch) -- All triggers emit actions; business logic subscribes once. - -### 4. Provider Registries (Strategy Pattern) - -- Replace hardcoded switch/if wiring with registries: - - tracker providers - - tokenizer providers - - translation providers - - subsync backend providers - -### 5. Shared IPC Contract - -- Single source of truth for IPC channels and payloads: - - `src/ipc/contract.ts` - - `src/ipc/main-api.ts` - - `src/ipc/renderer-api.ts` -- `preload.ts` and renderer consume typed wrappers instead of ad hoc channel strings. - -### 6. Subtitle Pipeline - -- Middleware/stage pipeline: - - ingest -> normalize -> tokenize -> merge -> enrich -> emit -- Files: - - `src/subtitle/pipeline.ts` - - `src/subtitle/stages/*.ts` - -### 7. Module-Owned Config Extensions - -- Keep `src/config/definitions.ts` as the central resolved output. -- Add registration path so modules can contribute config/runtime option metadata and defaults before final resolution. - -## Phased Delivery Plan - -## Phase 0: Baseline and Safety Net - -### Scope - -- Add architecture docs and migration guardrails. -- Increase tests around currently stable behavior before structural changes. - -### Changes - -- Add architecture decision notes: - - `docs/development.md` (new section: architecture/migration) - - `docs/architecture.md` (new) -- Add basic smoke tests for command routing, overlay visibility toggles, and config load behavior. - -### Exit Criteria - -- Existing behavior documented with acceptance checklist. -- CI/build + config tests green. - -## Phase 1: Extract I/O Surfaces - -### Scope - -- Isolate IPC and global shortcut registration from `src/main.ts` without changing semantics. - -### Changes - -- Create: - - `src/core/services/ipc-service.ts` - - `src/core/services/shortcut-service.ts` -- Move `ipcMain.handle/on` registration blocks from `src/main.ts` into `IpcService.register(...)`. -- Move global shortcut registration into `ShortcutService.registerGlobal(...)`. -- Keep old entrypoints in `main.ts` delegating to new service methods. - -### Exit Criteria - -- No user-visible behavior changes. -- `src/main.ts` shrinks materially. -- Manual verification: - - overlay toggles - - copy/mine shortcuts - - runtime options open/cycle - -## Phase 2: Introduce Action Bus - -### Scope - -- Canonicalize command execution path. - -### Changes - -- Add typed action bus (`src/core/action-bus.ts`, `src/core/actions.ts`). -- Convert these triggers to dispatch actions instead of direct calls: - - CLI handling (`handleCliCommand` path) - - shortcut handlers - - IPC command handlers -- Keep existing business functions as subscribers initially. - -### Exit Criteria - -- One action path per command (no duplicated behavior branches). -- Unit tests for command mapping (CLI -> action, shortcut -> action, IPC -> action). - -## Phase 3: Module Runtime Skeleton - -### Scope - -- Add module contract and migrate one low-risk feature first. - -### Changes - -- Introduce: - - `src/core/module.ts` - - `src/core/module-registry.ts` - - `src/core/app-context.ts` -- First migration target: runtime options - - create `src/modules/runtime-options/module.ts` - - wire existing `RuntimeOptionsManager` through module lifecycle. - -### Exit Criteria - -- Runtime options fully owned by module. -- Core app can start with module list and deterministic module init order. - -## Phase 4: Provider Registries - -### Scope - -- Remove hardcoded provider selection. - -### Changes - -- Window tracker: - - Replace switch in `src/window-trackers/index.ts` with registry API. - - Add provider objects for hyprland/sway/x11/macos. -- Tokenizer/translator/subsync: - - Add analogous registries in new provider directories. - -### Exit Criteria - -- Adding a provider requires adding one file + registration line. -- No edits required in composition root for new provider variants. - -## Phase 5: Shared IPC Contract - -### Scope - -- Eliminate channel/payload drift between main/preload/renderer. - -### Changes - -- Add typed IPC contract files under `src/ipc/`. -- Refactor `src/preload.ts` to generate API from shared contract wrappers. -- Refactor renderer calls to consume typed API interface from shared module. - -### Exit Criteria - -- Channel names declared once. -- Compile-time checking across main/preload/renderer for payload mismatch. - -## Phase 6: Subtitle Pipeline - -### Scope - -- Extract subtitle transformations into composable stages. - -### Changes - -- Create pipeline framework and migrate existing tokenization/merge flow: - - stage: normalize subtitle - - stage: tokenize (`MecabTokenizer` adapter) - - stage: merge tokens (`mergeTokens` adapter) - - stage: post-processing/enrichment hooks -- Integrate pipeline execution into subtitle update loop. - -### Exit Criteria - -- Current output parity for tokenization/merged token behavior. -- Stage-level tests for deterministic input/output. - -## Phase 7: Moduleized Integrations - -### Scope - -- Convert major features to modules in descending complexity. - -### Migration Order - -1. Jimaku -2. Texthooker/WebSocket bridge -3. Subsync -4. Anki integration - -### Exit Criteria - -- Each feature has independent init/start/stop lifecycle. -- App startup composes modules instead of hardcoded inline setup. - -## Recommended File Layout (Target) - -```text -src/ - core/ - app-orchestrator.ts - app-context.ts - actions.ts - action-bus.ts - module.ts - module-registry.ts - services/ - ipc-service.ts - shortcut-service.ts - overlay-service.ts - mpv-service.ts - subtitle-service.ts - modules/ - runtime-options/module.ts - jimaku/module.ts - texthooker/module.ts - subsync/module.ts - anki/module.ts - ipc/ - contract.ts - main-api.ts - renderer-api.ts - subtitle/ - pipeline.ts - stages/ - normalize.ts - tokenize-mecab.ts - merge-default.ts - enrich.ts -``` - -## PR Breakdown (Suggested) - -1. PR1: Phase 1 (`IpcService`, `ShortcutService`, no behavior changes) -2. PR2: Phase 2 (action bus + trigger mapping) -3. PR3: Phase 3 (module skeleton + runtime options module) -4. PR4: Phase 4 (window tracker registry + provider pattern) -5. PR5: Phase 5 (shared IPC contract) -6. PR6: Phase 6 (subtitle pipeline) -7. PR7+: Phase 7 (feature-by-feature module migrations) - -## Validation Matrix - -- Build/typecheck: - - `pnpm run build` -- Config correctness: - - `pnpm run test:config` -- Manual checks per PR: - - app start/stop/toggle CLI - - visible/invisible overlay control - - global shortcuts - - subtitle render and token display - - runtime options open + cycle - - anki mine flow + update-from-clipboard - -## Backward Compatibility Rules - -- Preserve existing CLI flags and IPC channel behavior during migration. -- Maintain config shape compatibility; only additive changes unless versioned migration is added. -- Keep default behavior equivalent for all supported compositor backends. - -## Key Risks and Mitigations - -- Risk: behavior regressions during extraction. - - Mitigation: move code verbatim first, refactor second; keep thin delegates in `main.ts` until stable. -- Risk: module lifecycle race conditions. - - Mitigation: explicit init order, idempotent `start/stop`, and startup dependency checks. -- Risk: IPC contract churn breaks renderer. - - Mitigation: contract wrappers and compile-time types; temporary compatibility adapters. -- Risk: feature coupling around anki/mine flows. - - Mitigation: defer high-coupling module migrations until action bus and shared app context are stable. - -## Definition of Done (Program-Level) - -- `src/main.ts` reduced to bootstrap/composition responsibilities. -- New feature prototype can be added as an isolated module with: - - module file - - optional provider registration - - optional config schema registration - - no invasive edits across unrelated subsystems -- IPC and command routing are single-path and typed. -- Existing user workflows remain intact. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index deea54f..af1a589 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -24,8 +24,8 @@ export default { { text: 'Docs', link: '/' }, { text: 'Installation', link: '/installation' }, { text: 'Usage', link: '/usage' }, + { text: 'Mining', link: '/mining-workflow' }, { text: 'Configuration', link: '/configuration' }, - { text: 'Development', link: '/development' }, { text: 'Architecture', link: '/architecture' }, ], sidebar: [ @@ -35,12 +35,21 @@ export default { { text: 'Overview', link: '/' }, { text: 'Installation', link: '/installation' }, { text: 'Usage', link: '/usage' }, + { text: 'Mining Workflow', link: '/mining-workflow' }, ], }, { text: 'Reference', items: [ { text: 'Configuration', link: '/configuration' }, + { text: 'Anki Integration', link: '/anki-integration' }, + { text: 'MPV Plugin', link: '/mpv-plugin' }, + { text: 'Troubleshooting', link: '/troubleshooting' }, + ], + }, + { + text: 'Development', + items: [ { text: 'Development', link: '/development' }, { text: 'Architecture', link: '/architecture' }, ], diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 257b89e..ab01c93 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,7 +1,6 @@ import DefaultTheme from 'vitepress/theme'; import { useRoute } from 'vitepress'; import { nextTick, onMounted, watch } from 'vue'; -import mermaid from 'mermaid'; import '@catppuccin/vitepress/theme/macchiato/mauve.css'; import './mermaid-modal.css'; @@ -107,7 +106,8 @@ function attachMermaidInteractions(nodes: HTMLElement[]) { async function getMermaid() { if (!mermaidLoader) { - mermaidLoader = Promise.resolve().then(() => { + mermaidLoader = import('mermaid').then((module) => { + const mermaid = module.default; mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', diff --git a/docs/README.md b/docs/README.md index a6d58ff..feaa4fe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,37 +1,23 @@ # Documentation -Use this directory for detailed SubMiner documentation. +SubMiner documentation is built with [VitePress](https://vitepress.dev/). ## Local Docs Site -Run the VitePress docs site locally: - ```bash -pnpm run docs:dev +make docs-dev # Dev server at http://localhost:5173 +make docs # Build static output +make docs-preview # Preview built site at http://localhost:4173 ``` -Build static docs output: +## Pages -```bash -pnpm run docs:build -``` - -- [Installation](/installation) - - Platform requirements - - AppImage / macOS / source installs - - mpv plugin setup -- [Usage](/usage) - - Script vs plugin workflow - - Running SubMiner with mpv - - Keybindings and runtime behavior -- [Configuration](/configuration) - - Full config file reference and option details -- [Development](/development) - - Contributor notes - - Architecture and extension rules - - Environment variables - - License and acknowledgments -- [Architecture](/architecture) - - Service-oriented runtime structure - - Composition and lifecycle model - - Extension design rules +- [Installation](/installation) — Platform requirements, AppImage/macOS/source installs, mpv plugin +- [Usage](/usage) — Script vs plugin workflow, keybindings, YouTube playback +- [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, overlay layers, card creation +- [Configuration](/configuration) — Full config file reference and option details +- [Anki Integration](/anki-integration) — AnkiConnect setup, field mapping, media generation, field grouping +- [MPV Plugin](/mpv-plugin) — Chord keybindings, subminer.conf options, script messages +- [Troubleshooting](/troubleshooting) — Common issues and solutions by category +- [Development](/development) — Building, testing, contributing, environment variables +- [Architecture](/architecture) — Service-oriented design, composition model, extension rules diff --git a/docs/anki-integration.md b/docs/anki-integration.md new file mode 100644 index 0000000..850a5f1 --- /dev/null +++ b/docs/anki-integration.md @@ -0,0 +1,262 @@ +# Anki Integration + +SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots. + +## Prerequisites + +1. Install [Anki](https://apps.ankiweb.net/). +2. Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on (code: `2055492159`). +3. Keep Anki running while using SubMiner. + +AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config. + +## How Polling Works + +SubMiner polls AnkiConnect at a regular interval (default: 3 seconds, configurable via `ankiConnect.pollingRate`) to detect new cards. When it finds a card that was added since the last poll: + +1. Checks if a duplicate expression already exists (for field grouping). +2. Updates the sentence field with the current subtitle. +3. Generates and uploads audio and image media. +4. Fills the translation field from the secondary subtitle or AI. +5. Writes metadata to the miscInfo field. + +Polling uses the query `"deck:" added:1` to find recently added cards. If no deck is configured, it searches all decks. + +## Field Mapping + +SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`: + +```jsonc +"ankiConnect": { + "fields": { + "audio": "ExpressionAudio", // audio clip from the video + "image": "Picture", // screenshot or animated clip + "sentence": "Sentence", // subtitle text + "miscInfo": "MiscInfo", // metadata (filename, timestamp) + "translation": "SelectionText" // secondary sub or AI translation + } +} +``` + +Field names must match your Anki note type exactly (case-sensitive). If a configured field does not exist on the note type, SubMiner skips it without error. + +### Minimal Config + +If you only want sentence and audio on your cards: + +```jsonc +"ankiConnect": { + "enabled": true, + "fields": { + "sentence": "Sentence", + "audio": "ExpressionAudio" + } +} +``` + +## Media Generation + +SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg must be installed and on `PATH`. + +### Audio + +Audio is extracted from the video file using the subtitle's start and end timestamps, with configurable padding added before and after. + +```jsonc +"ankiConnect": { + "media": { + "generateAudio": true, + "audioPadding": 0.5, // seconds before and after subtitle timing + "maxMediaDuration": 30 // cap total duration in seconds + } +} +``` + +Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. + +The audio is uploaded to Anki's media folder and inserted as `[sound:audio_.mp3]`. + +### Screenshots (Static) + +A single frame is captured at the current playback position. + +```jsonc +"ankiConnect": { + "media": { + "generateImage": true, + "imageType": "static", + "imageFormat": "jpg", // "jpg", "png", or "webp" + "imageQuality": 92, // 1–100 + "imageMaxWidth": null, // optional, preserves aspect ratio + "imageMaxHeight": null + } +} +``` + +### Animated Clips (AVIF) + +Instead of a static screenshot, SubMiner can generate an animated AVIF covering the subtitle duration. + +```jsonc +"ankiConnect": { + "media": { + "generateImage": true, + "imageType": "avif", + "animatedFps": 10, + "animatedMaxWidth": 640, + "animatedMaxHeight": null, + "animatedCrf": 35 // 0–63, lower = better quality + } +} +``` + +Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. + +### Behavior Options + +```jsonc +"ankiConnect": { + "behavior": { + "overwriteAudio": true, // replace existing audio, or append + "overwriteImage": true, // replace existing image, or append + "mediaInsertMode": "append", // "append" or "prepend" to field content + "autoUpdateNewCards": true, // auto-update when new card detected + "notificationType": "osd" // "osd", "system", "both", or "none" + } +} +``` + +## AI Translation + +SubMiner can auto-translate the mined sentence and fill the translation field. By default, if a secondary subtitle track is available, its text is used. When AI is enabled, SubMiner calls an LLM API instead. + +```jsonc +"ankiConnect": { + "ai": { + "enabled": true, + "alwaysUseAiTranslation": false, // true = ignore secondary sub + "apiKey": "sk-...", + "model": "openai/gpt-4o-mini", + "baseUrl": "https://openrouter.ai/api", + "targetLanguage": "English", + "systemPrompt": "You are a translation engine. Return only the translation." + } +} +``` + +Translation priority: + +1. If `alwaysUseAiTranslation` is `true`, always call the AI API. +2. If a secondary subtitle is available, use it as the translation. +3. If AI is enabled and no secondary subtitle exists, call the AI API. +4. Otherwise, leave the field empty. + +## Sentence Cards (Lapis) + +SubMiner can create standalone sentence cards (without a word/expression) using a separate note type. This is designed for use with [Lapis](https://github.com/donkuri/Lapis) and similar sentence-focused note types. + +```jsonc +"ankiConnect": { + "isLapis": { + "enabled": true, + "sentenceCardModel": "Japanese sentences", + "sentenceCardSentenceField": "Sentence", + "sentenceCardAudioField": "SentenceAudio" + } +} +``` + +Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is created directly via AnkiConnect with the sentence, audio, and image filled in. + +To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine. + +## Field Grouping (Kiku) + +When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/donkuri/Kiku) that support grouped sentence/audio/image fields. + +```jsonc +"ankiConnect": { + "isKiku": { + "enabled": true, + "fieldGrouping": "manual", // "auto", "manual", or "disabled" + "deleteDuplicateInAuto": true // delete new card after auto-merge + } +} +``` + +### Modes + +**Disabled** (`"disabled"`): No duplicate detection. Each card is independent. + +**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both sentences, audio clips, and images are preserved. If `deleteDuplicateInAuto` is true, the new card is deleted after merging. + +**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically. + +### What Gets Merged + +| Field | Merge behavior | +| --- | --- | +| Sentence | Both sentences preserved, labeled `[Original]` / `[Duplicate]` | +| Audio | Both `[sound:...]` entries kept | +| Image | Both images kept | + +### Keyboard Shortcuts in the Modal + +| Key | Action | +| --- | --- | +| `1` / `2` | Select card 1 or card 2 to keep | +| `Enter` | Confirm selection | +| `Esc` | Cancel (keep both cards unchanged) | + +## Full Config Example + +```jsonc +{ + "ankiConnect": { + "enabled": true, + "url": "http://127.0.0.1:8765", + "pollingRate": 3000, + "fields": { + "audio": "ExpressionAudio", + "image": "Picture", + "sentence": "Sentence", + "miscInfo": "MiscInfo", + "translation": "SelectionText" + }, + "media": { + "generateAudio": true, + "generateImage": true, + "imageType": "static", + "imageFormat": "jpg", + "imageQuality": 92, + "audioPadding": 0.5, + "maxMediaDuration": 30 + }, + "behavior": { + "overwriteAudio": true, + "overwriteImage": true, + "mediaInsertMode": "append", + "autoUpdateNewCards": true, + "notificationType": "osd" + }, + "ai": { + "enabled": false, + "apiKey": "", + "model": "openai/gpt-4o-mini", + "baseUrl": "https://openrouter.ai/api", + "targetLanguage": "English" + }, + "isKiku": { + "enabled": false, + "fieldGrouping": "disabled", + "deleteDuplicateInAuto": true + }, + "isLapis": { + "enabled": false, + "sentenceCardModel": "Japanese sentences", + "sentenceCardSentenceField": "Sentence", + "sentenceCardAudioField": "SentenceAudio" + } + } +} +``` diff --git a/docs/architecture.md b/docs/architecture.md index b18cf18..6430c68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -SubMiner uses a service-oriented Electron main-process architecture where `src/main.ts` acts as the composition root and behavior lives in small runtime services under `src/core/services`. +SubMiner uses a service-oriented Electron main-process architecture where `src/main.ts` (~1,400 lines) acts as the composition root and behavior lives in focused services under `src/core/services/` (~35 service files). ## Goals @@ -12,54 +12,102 @@ SubMiner uses a service-oriented Electron main-process architecture where `src/m - services compose through explicit inputs/outputs - orchestration is separate from implementation -## Current Structure +## Project Structure -- `src/main.ts` - - Composition root for lifecycle wiring and non-overlay runtime state. - - Owns long-lived process state for trackers, runtime flags, and client instances. - - Delegates behavior to services. -- `src/core/services/overlay-manager-service.ts` - - Owns overlay/window state (`mainWindow`, `invisibleWindow`, visible/invisible overlay flags). - - Provides a narrow state API used by `main.ts` and overlay services. -- `src/core/services/*` - - Stateless or narrowly stateful units for a specific responsibility. - - Examples: startup bootstrap/ready flow, app lifecycle wiring, CLI command handling, IPC registration, overlay visibility, MPV IPC behavior, shortcut registration, subtitle websocket, jimaku/subsync helpers. -- `src/core/utils/*` - - Pure helpers and coercion/config utilities. -- `src/cli/*` - - CLI parsing and help output. -- `src/config/*` - - Config schema/definitions, defaults, validation, and template generation. -- `src/window-trackers/*` - - Backend-specific tracker implementations plus selection index. -- `src/jimaku/*`, `src/subsync/*` - - Domain-specific integration helpers. +```text +src/ + main.ts # Composition root — lifecycle wiring and state ownership + preload.ts # Electron preload bridge + types.ts # Shared type definitions + core/ + services/ # ~35 focused service modules (see below) + utils/ # Pure helpers and coercion/config utilities + cli/ # CLI parsing and help output + config/ # Config schema, defaults, validation, template generation + renderer/ # Overlay renderer (HTML/CSS/JS) + window-trackers/ # Backend-specific tracker implementations (Hyprland, X11, macOS) + jimaku/ # Jimaku API integration helpers + subsync/ # Subtitle sync (alass/ffsubsync) helpers + subtitle/ # Subtitle processing utilities + tokenizers/ # Tokenizer implementations + token-mergers/ # Token merge strategies + translators/ # AI translation providers +``` + +### Service Layer (`src/core/services/`) + +- **Startup** — `startup-service`, `app-lifecycle-service` +- **Overlay** — `overlay-manager-service`, `overlay-window-service`, `overlay-visibility-service`, `overlay-bridge-service`, `overlay-runtime-init-service` +- **Shortcuts** — `shortcut-service`, `overlay-shortcut-service`, `overlay-shortcut-handler`, `shortcut-fallback-service`, `numeric-shortcut-service` +- **MPV** — `mpv-service`, `mpv-control-service`, `mpv-render-metrics-service` +- **IPC** — `ipc-service`, `ipc-command-service`, `runtime-options-ipc-service` +- **Mining** — `mining-service`, `field-grouping-service`, `field-grouping-overlay-service`, `anki-jimaku-service`, `anki-jimaku-ipc-service` +- **Subtitles** — `subtitle-ws-service`, `subtitle-position-service`, `secondary-subtitle-service`, `tokenizer-service` +- **Integrations** — `jimaku-service`, `subsync-service`, `subsync-runner-service`, `texthooker-service`, `yomitan-extension-loader-service`, `yomitan-settings-service` +- **Config** — `runtime-config-service`, `cli-command-service` ## Flow Diagram ```mermaid flowchart TD - Main["src/main.ts\n(composition root)"] --> Startup["runStartupBootstrapRuntimeService"] - Main --> Lifecycle["startAppLifecycleService"] - Lifecycle --> AppReady["runAppReadyRuntimeService"] + classDef root fill:#1f2937,stroke:#111827,color:#f9fafb,stroke-width:1.5px; + classDef orchestration fill:#334155,stroke:#0f172a,color:#e2e8f0; + classDef domain fill:#1d4ed8,stroke:#1e3a8a,color:#dbeafe; + classDef boundary fill:#065f46,stroke:#064e3b,color:#d1fae5; - Main --> OverlayMgr["overlay-manager-service"] - Main --> Ipc["ipc-service / ipc-command-service"] - Main --> Mpv["mpv-service / mpv-control-service"] - Main --> Shortcuts["shortcut-service / overlay-shortcut-service"] - Main --> RuntimeOpts["runtime-options-ipc-service"] - Main --> Subtitle["subtitle-ws-service / secondary-subtitle-service"] + subgraph Entry["Entrypoint"] + Main["src/main.ts\ncomposition root"] + end + class Main root; - Main --> Config["src/config/*"] - Main --> Cli["src/cli/*"] - Main --> Trackers["src/window-trackers/*"] - Main --> Integrations["src/jimaku/* + src/subsync/*"] + subgraph Boot["Startup Orchestration"] + Startup["startup-service"] + Lifecycle["app-lifecycle-service"] + AppReady["app-ready flow"] + end + class Startup,Lifecycle,AppReady orchestration; - OverlayMgr --> OverlayWindow["overlay-window-service"] - OverlayMgr --> OverlayVisibility["overlay-visibility-service"] - Mpv --> Subtitle - Ipc --> RuntimeOpts - Shortcuts --> OverlayMgr + subgraph Runtime["Runtime Domains"] + OverlayMgr["overlay-manager-service"] + OverlayWindow["overlay-window-service"] + OverlayVisibility["overlay-visibility-service"] + Ipc["ipc-service\nipc-command-service"] + RuntimeOpts["runtime-options-ipc-service"] + Mpv["mpv-service\nmpv-control-service"] + Subtitle["subtitle-ws-service\nsecondary-subtitle-service"] + Shortcuts["shortcut-service\noverlay-shortcut-service"] + end + class OverlayMgr,OverlayWindow,OverlayVisibility,Ipc,RuntimeOpts,Mpv,Subtitle,Shortcuts domain; + + subgraph Adapters["External Boundaries"] + Config["src/config/*"] + Cli["src/cli/*"] + Trackers["src/window-trackers/*"] + Integrations["src/jimaku/*\nsrc/subsync/*"] + end + class Config,Cli,Trackers,Integrations boundary; + + Main -->|bootstraps| Startup + Main -->|registers lifecycle hooks| Lifecycle + Lifecycle -->|triggers| AppReady + + Main -->|wires| OverlayMgr + Main -->|wires| Ipc + Main -->|wires| Mpv + Main -->|wires| Shortcuts + Main -->|wires| RuntimeOpts + Main -->|wires| Subtitle + + Main -->|loads| Config + Main -->|parses| Cli + Main -->|delegates backend state| Trackers + Main -->|calls integrations| Integrations + + OverlayMgr -->|creates window| OverlayWindow + OverlayMgr -->|applies visibility policy| OverlayVisibility + Ipc -->|updates| RuntimeOpts + Mpv -->|feeds timing + subtitle context| Subtitle + Shortcuts -->|drives overlay actions| OverlayMgr ``` ## Composition Pattern @@ -75,36 +123,54 @@ This keeps side effects explicit and makes behavior easy to unit-test with fakes ## Lifecycle Model -- Startup: - - `runStartupBootstrapRuntimeService` handles initial argv/env/backend setup and decides generate-config flow vs app lifecycle start. +- **Startup:** + - `startup-service` handles initial argv/env/backend setup and decides generate-config flow vs app lifecycle start. - `app-lifecycle-service` handles Electron single-instance + lifecycle event registration. - - `runAppReadyRuntimeService` performs ready-time initialization (config load, websocket policy, tokenizer/tracker setup, overlay auto-init decisions). -- Runtime: + - App-ready flow performs ready-time initialization (config load, websocket policy, tokenizer/tracker setup, overlay auto-init decisions). +- **Runtime:** - CLI/shortcut/IPC events map to service calls. - Overlay and MPV state sync through dedicated services. - Runtime options and mining flows are coordinated via service boundaries. -- Shutdown: - - `startAppLifecycleService` registers cleanup hooks (`will-quit`) while teardown behavior stays delegated to focused services from `main.ts`. +- **Shutdown:** + - `app-lifecycle-service` registers cleanup hooks (`will-quit`) while teardown behavior stays delegated to focused services from `main.ts`. ```mermaid -flowchart LR - Args["CLI args"] --> Bootstrap["runStartupBootstrapRuntimeService"] - Bootstrap -->|generate-config| Exit["exit"] - Bootstrap -->|normal start| AppLifecycle["startAppLifecycleService"] - AppLifecycle --> Ready["runAppReadyRuntimeService"] - Ready --> Runtime["IPC + shortcuts + mpv events"] - Runtime --> Overlay["overlay visibility + mining actions"] - Runtime --> Subsync["subsync + secondary sub flows"] - Runtime --> WillQuit["app will-quit"] - WillQuit --> Cleanup["service-level cleanup + unregister"] +flowchart TD + classDef phase fill:#334155,stroke:#0f172a,color:#e2e8f0; + classDef decision fill:#7c2d12,stroke:#431407,color:#ffedd5; + classDef runtime fill:#0369a1,stroke:#0c4a6e,color:#e0f2fe; + classDef shutdown fill:#14532d,stroke:#052e16,color:#dcfce7; + + Args["CLI args / env"] --> Startup["startup-service"] + class Args,Startup phase; + + Startup --> Decision{"generate-config?"} + class Decision decision; + + Decision -->|yes| WriteConfig["write config + exit"] + Decision -->|no| Lifecycle["app-lifecycle-service"] + class WriteConfig,Lifecycle phase; + + Lifecycle --> Ready["app-ready flow\n(config + websocket policy + tracker/tokenizer init)"] + class Ready phase; + + Ready --> RuntimeBus["event loop:\nIPC + shortcuts + mpv events"] + RuntimeBus --> Overlay["overlay visibility + mining actions"] + RuntimeBus --> Subtitle["subtitle + secondary-subtitle processing"] + RuntimeBus --> Subsync["subsync / jimaku integration actions"] + class RuntimeBus,Overlay,Subtitle,Subsync runtime; + + RuntimeBus --> WillQuit["Electron will-quit"] + WillQuit --> Cleanup["service-level teardown\n(unregister hooks, close resources)"] + class WillQuit,Cleanup shutdown; ``` ## Why This Design -- Smaller blast radius: changing one feature usually touches one service. -- Better testability: most behavior can be tested without Electron windows/mpv. -- Better reviewability: PRs can be scoped to one subsystem. -- Backward compatibility: CLI flags and IPC channels can remain stable while internals evolve. +- **Smaller blast radius:** changing one feature usually touches one service. +- **Better testability:** most behavior can be tested without Electron windows/mpv. +- **Better reviewability:** PRs can be scoped to one subsystem. +- **Backward compatibility:** CLI flags and IPC channels can remain stable while internals evolve. ## Extension Rules diff --git a/docs/development.md b/docs/development.md index 3ea5724..559d25a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,20 +1,99 @@ -# Contributor Note +# Development -To add or change a config option, update `src/config/definitions.ts` first. Defaults, runtime-option metadata, and generated `config.example.jsonc` are derived from this centralized source. +## Prerequisites -## Architecture +- [Node.js](https://nodejs.org/) (LTS) +- [pnpm](https://pnpm.io/) +- [Bun](https://bun.sh) (for the `subminer` wrapper script) -The current runtime design, composition model, and extension guidelines are documented in [`architecture.md`](/architecture). +## Setup -Contributor guidance: +```bash +git clone https://github.com/ksyasuda/SubMiner.git +cd SubMiner +make deps +# or manually: +pnpm install +pnpm -C vendor/texthooker-ui install +``` + +## Building + +```bash +# TypeScript compile (fast, for development) +pnpm run build + +# Full platform build (includes texthooker-ui + AppImage/DMG) +make build + +# Platform-specific builds +make build-linux # Linux AppImage +make build-macos # macOS DMG + ZIP (signed) +make build-macos-unsigned # macOS DMG + ZIP (unsigned) +``` + +## Running Locally + +```bash +pnpm run dev # builds + launches with --start --dev flags +``` + +## Testing + +```bash +pnpm run test:config # Config schema and validation tests +pnpm run test:core # Core service tests (~67 tests) +pnpm run test:subtitle # Subtitle pipeline tests +``` + +All test commands build first, then run via Node's built-in test runner (`node --test`). + +## Config Generation + +```bash +# Generate default config to ~/.config/SubMiner/config.jsonc +make generate-config + +# Regenerate the repo's config.example.jsonc from centralized defaults +make generate-example-config +# or: pnpm run generate:config-example +``` + +## Documentation Site + +The docs use [VitePress](https://vitepress.dev/): + +```bash +make docs-dev # Dev server at http://localhost:5173 +make docs # Build static output +make docs-preview # Preview built site at http://localhost:4173 +``` + +## Makefile Reference + +Run `make help` for a full list of targets. Key ones: + +| Target | Description | +| --- | --- | +| `make build` | Build platform package for detected OS | +| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) | +| `make install-plugin` | Install mpv Lua plugin and config | +| `make deps` | Install JS dependencies (root + texthooker-ui) | +| `make generate-config` | Generate default config from centralized registry | +| `make docs-dev` | Run VitePress dev server | + +## Contributor Notes + +- To add or change a config option, update `src/config/definitions.ts` first. Defaults, runtime-option metadata, and generated `config.example.jsonc` are derived from this centralized source. - Overlay window/visibility state is owned by `src/core/services/overlay-manager-service.ts`. - Prefer direct inline deps objects in `main.ts` for simple pass-through wiring. - Add a helper/adapter service only when it performs meaningful adaptation, validation, or reuse (not identity mapping). +- See [Architecture](/architecture) for the composition model and extension rules. ## Environment Variables -| Variable | Description | -| ------------------------ | ---------------------------------------------- | +| Variable | Description | +| --- | --- | | `SUBMINER_APPIMAGE_PATH` | Override AppImage location for subminer script | | `SUBMINER_YT_SUBGEN_MODE` | Override `youtubeSubgen.mode` for launcher | | `SUBMINER_WHISPER_BIN` | Override `youtubeSubgen.whisperBin` for launcher | diff --git a/docs/index.md b/docs/index.md index f995aa9..4cf4cfc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,15 +15,15 @@ hero: - theme: brand text: Installation link: /installation + - theme: alt + text: Mining Workflow + link: /mining-workflow - theme: alt text: Configuration link: /configuration - theme: alt - text: Development - link: /development - - theme: alt - text: Architecture - link: /architecture + text: Troubleshooting + link: /troubleshooting features: - title: End-to-end workflow @@ -33,11 +33,3 @@ features: - title: Contributor docs details: Build, test, and package SubMiner with the development notes in this docs set. --- - - diff --git a/docs/mining-workflow.md b/docs/mining-workflow.md new file mode 100644 index 0000000..0082e74 --- /dev/null +++ b/docs/mining-workflow.md @@ -0,0 +1,153 @@ +# Mining Workflow + +This guide walks through the sentence mining loop — from watching a video to creating Anki cards with audio, screenshots, and context. + +## Overview + +SubMiner runs as a transparent overlay on top of mpv. As subtitles play, the overlay displays them as interactive text. You click a word to look it up with Yomitan, then create an Anki card with a single action. SubMiner automatically attaches the sentence, audio clip, and screenshot. + +```text +Watch video → See subtitle → Click word → Yomitan lookup → Add to Anki + ↓ + SubMiner auto-fills: + sentence, audio, image, translation +``` + +## The Two Overlay Layers + +SubMiner uses two overlay layers, each serving a different purpose. + +### Visible Overlay + +The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This layer is styled independently from mpv subtitles and supports: + +- Word-level click targets for Yomitan lookup +- Right-click to pause/resume +- Right-click + drag to reposition subtitles +- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options + +Toggle with `Alt+Shift+O` (global) or `y-t` (mpv plugin). + +### Invisible Overlay + +The invisible overlay is a transparent layer that aligns precisely with mpv's own subtitle rendering. It reproduces the subtitle text at the exact position and size mpv uses, so you can click directly on the subtitles you see in the video. + +This layer uses mpv's subtitle render metrics (font size, margins, position, scaling) and converts them from mpv's scaled-pixel system (reference height 720) to actual screen pixels. + +Toggle with `Alt+Shift+I` (global) or `y-i` (mpv plugin). + +**Position edit mode**: Press `Ctrl/Cmd+Shift+P` to enter edit mode, then use arrow keys (or `hjkl`) to nudge the position. `Shift` moves 4 px at a time. Press `Enter` or `Ctrl+S` to save, `Esc` to cancel. + +## Looking Up Words + +### On the Visible Overlay + +1. Hover over the subtitle area — the overlay activates pointer events. +2. Click a word. SubMiner selects it using Unicode-aware word boundary detection (`Intl.Segmenter`). +3. Yomitan detects the text selection and opens its popup with dictionary results. +4. From the Yomitan popup, you can add the word directly to Anki. + +### On the Invisible Overlay + +1. The invisible layer sits over mpv's own subtitle text. +2. Click on any word in the subtitle — SubMiner maps your click position to the underlying text. +3. On macOS, word selection happens automatically on hover. +4. Yomitan popup appears for lookup and card creation. + +## Creating Anki Cards + +There are three ways to create cards, depending on your workflow. + +### 1. Auto-Update from Yomitan + +This is the most common flow. Yomitan creates a card in Anki, and SubMiner detects it via polling and enriches it automatically. + +1. Click a word → Yomitan popup appears. +2. Click the Anki icon in Yomitan to add the word. +3. SubMiner detects the new card (polls AnkiConnect every 3 seconds by default). +4. SubMiner updates the card with: + - **Sentence**: The current subtitle line. + - **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding). + - **Image**: A screenshot or animated clip from the current playback position. + - **Translation**: From the secondary subtitle track, or generated via AI if configured. + - **MiscInfo**: Metadata like filename and timestamp. + +Configure which fields to fill in `ankiConnect.fields`. See [Anki Integration](/anki-integration) for details. + +### 2. Mine Sentence (Hotkey) + +Create a standalone sentence card without going through Yomitan: + +- **Mine current sentence**: `Ctrl/Cmd+S` (configurable via `shortcuts.mineSentence`) +- **Mine multiple lines**: `Ctrl/Cmd+Shift+S` followed by a digit 1–9 to select how many recent subtitle lines to combine. + +The sentence card uses the note type configured in `isLapis.sentenceCardModel` with the sentence and audio fields mapped by `isLapis.sentenceCardSentenceField` and `isLapis.sentenceCardAudioField`. + +### 3. Mark as Audio Card + +After adding a word via Yomitan, press the audio card shortcut to overwrite the audio with a longer clip spanning the full subtitle timing. + +## Secondary Subtitles + +SubMiner can display a secondary subtitle track (typically English) alongside the primary Japanese subtitles. This is useful for: + +- Quick comprehension checks without leaving the mining flow. +- Auto-populating the translation field on mined cards. + +### Display Modes + +Cycle through modes with the configured shortcut: + +- **Hidden**: Secondary subtitle not shown. +- **Visible**: Always displayed below the primary subtitle. +- **Hover**: Only shown when you hover over the primary subtitle. + +When a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it). + +## Field Grouping (Kiku) + +If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/donkuri/Kiku) and similar note types that support grouped fields. + +### How It Works + +1. You add a word via Yomitan. +2. SubMiner detects the new card and checks if a card with the same expression already exists. +3. If a duplicate is found: + - **Auto mode** (`fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted. + - **Manual mode** (`fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming. + +### What Gets Merged + +- **Sentence fields**: Both sentences kept, marked with `[Original]` and `[Duplicate]`. +- **Audio fields**: Both audio clips preserved as separate `[sound:...]` entries. +- **Image fields**: Both images preserved. + +Configure in `ankiConnect.isKiku`. See [Anki Integration](/anki-integration#field-grouping-kiku) for the full reference. + +## Jimaku Subtitle Search + +SubMiner integrates with [Jimaku](https://jimaku.cc) to search and download subtitle files for anime directly from the overlay. + +1. Open the Jimaku modal via the configured shortcut (`Ctrl+Alt+J` by default). +2. SubMiner auto-fills the search from the current video filename (title, season, episode). +3. Browse matching entries and select a subtitle file to download. +4. The subtitle is loaded into mpv as a new track. + +Requires an internet connection. An API key (`jimaku.apiKey`) is optional but recommended for higher rate limits. + +## Texthooker + +SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools — such as a browser-based Yomitan instance — to receive subtitle text in real time. + +The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan. + +## Subtitle Sync (Subsync) + +If your subtitle file is out of sync with the audio, SubMiner can resynchronize it using [alass](https://github.com/kaegi/alass) or [ffsubsync](https://github.com/smacke/ffsubsync). + +1. Open the subsync modal from the overlay. +2. Select the sync engine (alass or ffsubsync). +3. For alass, select a reference subtitle track from the video. +4. SubMiner runs the sync and reloads the corrected subtitle. + +Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. diff --git a/docs/mpv-plugin.md b/docs/mpv-plugin.md new file mode 100644 index 0000000..a657b40 --- /dev/null +++ b/docs/mpv-plugin.md @@ -0,0 +1,174 @@ +# MPV Plugin + +The SubMiner mpv plugin (`subminer.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags. + +## Installation + +```bash +cp plugin/subminer.lua ~/.config/mpv/scripts/ +cp plugin/subminer.conf ~/.config/mpv/script-opts/ +# or: make install-plugin +``` + +mpv must have IPC enabled for SubMiner to connect: + +```ini +# ~/.config/mpv/mpv.conf +input-ipc-server=/tmp/subminer-socket +``` + +## Keybindings + +All keybindings use a `y` chord prefix — press `y`, then the second key: + +| Chord | Action | +| --- | --- | +| `y-y` | Open menu | +| `y-s` | Start overlay | +| `y-S` | Stop overlay | +| `y-t` | Toggle visible overlay | +| `y-i` | Toggle invisible overlay | +| `y-I` | Show invisible overlay | +| `y-u` | Hide invisible overlay | +| `y-o` | Open settings window | +| `y-r` | Restart overlay | +| `y-c` | Check status | + +## Menu + +Press `y-y` to open an interactive menu in mpv's OSD: + +```text +SubMiner: +1. Start overlay +2. Stop overlay +3. Toggle overlay +4. Toggle invisible overlay +5. Open options +6. Restart overlay +7. Check status +``` + +Select an item by pressing its number. + +## Configuration + +Create or edit `~/.config/mpv/script-opts/subminer.conf`: + +```ini +# Path to SubMiner binary. Leave empty for auto-detection. +binary_path= + +# MPV IPC socket path. Must match input-ipc-server in mpv.conf. +socket_path=/tmp/subminer-socket + +# Enable the texthooker WebSocket server. +texthooker_enabled=yes + +# Port for the texthooker server. +texthooker_port=5174 + +# Window manager backend: auto, hyprland, sway, x11, macos. +backend=auto + +# Start the overlay automatically when a file is loaded. +auto_start=no + +# Show the visible overlay on auto-start. +auto_start_visible_overlay=no + +# Invisible overlay startup: platform-default, visible, hidden. +# platform-default = hidden on Linux, visible on macOS/Windows. +auto_start_invisible_overlay=platform-default + +# Show OSD messages for overlay status changes. +osd_messages=yes + +# Logging level: debug, info, warn, error. +log_level=info +``` + +### Option Reference + +| Option | Default | Values | Description | +| --- | --- | --- | --- | +| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary | +| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path | +| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server | +| `texthooker_port` | `5174` | 1–65535 | Texthooker server port | +| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend | +| `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load | +| `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start | +| `auto_start_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start | +| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages | +| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity | + +## Binary Auto-Detection + +When `binary_path` is empty, the plugin searches platform-specific locations: + +**Linux:** +1. `~/.local/bin/SubMiner.AppImage` +2. `/opt/SubMiner/SubMiner.AppImage` +3. `/usr/local/bin/SubMiner` +4. `/usr/bin/SubMiner` + +**macOS:** +1. `/Applications/SubMiner.app/Contents/MacOS/SubMiner` +2. `~/Applications/SubMiner.app/Contents/MacOS/SubMiner` + +**Windows:** +1. `C:\Program Files\SubMiner\SubMiner.exe` +2. `C:\Program Files (x86)\SubMiner\SubMiner.exe` +3. `C:\SubMiner\SubMiner.exe` + +## Backend Detection + +When `backend=auto`, the plugin detects the window manager: + +1. **macOS** — detected via platform or `OSTYPE`. +2. **Hyprland** — detected via `HYPRLAND_INSTANCE_SIGNATURE`. +3. **Sway** — detected via `SWAYSOCK`. +4. **X11** — detected via `XDG_SESSION_TYPE=x11` or `DISPLAY`. +5. **Fallback** — defaults to X11 with a warning. + +## Script Messages + +The plugin can be controlled from other mpv scripts or the mpv command line using script messages: + +``` +script-message subminer-start +script-message subminer-stop +script-message subminer-toggle +script-message subminer-toggle-invisible +script-message subminer-show-invisible +script-message subminer-hide-invisible +script-message subminer-menu +script-message subminer-options +script-message subminer-restart +script-message subminer-status +``` + +The `subminer-start` message accepts overrides: + +``` +script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug +``` + +## Lifecycle + +- **File loaded**: If `auto_start=yes`, the plugin starts the overlay and applies visibility preferences after a short delay. +- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server. +- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first. + +## Using with the `subminer` Wrapper + +The `subminer` wrapper script handles mpv launch, socket setup, and overlay lifecycle automatically. You do not need the plugin if you always use the wrapper. + +The plugin is useful when you: + +- Launch mpv from other tools (file managers, media centers). +- Want on-demand overlay control without the wrapper. +- Use mpv's built-in file browser or playlist features. + +You can install both — the plugin provides chord keybindings for convenience, while the wrapper handles the full lifecycle. diff --git a/docs/refactor-main-checklist.md b/docs/refactor-main-checklist.md deleted file mode 100644 index d223128..0000000 --- a/docs/refactor-main-checklist.md +++ /dev/null @@ -1,46 +0,0 @@ -# Main.ts Refactor Checklist - -This checklist is the safety net for `src/main.ts` decomposition work. - -## Invariants (Do Not Break) - -- Keep all existing CLI flags and aliases working. -- Keep IPC channel names and payload shapes backward-compatible. -- Preserve overlay behavior: - - visible overlay toggles and follows expected state - - invisible overlay toggles and mouse passthrough behavior -- Preserve MPV integration behavior: - - connect/disconnect flows - - subtitle updates and overlay updates -- Preserve texthooker mode (`--texthooker`) and subtitle websocket behavior. -- Preserve mining/runtime options actions and trigger paths. - -## Per-PR Required Automated Checks - -- `pnpm run build` -- `pnpm run test:config` -- `pnpm run test:core` -- Current line gate script for milestone: - - Example Gate 1: `pnpm run check:main-lines:gate1` - -## Per-PR Manual Smoke Checks - -- CLI: - - `electron . --help` output is valid - - `--start`, `--stop`, `--toggle` still route correctly -- Overlay: - - visible overlay show/hide/toggle works - - invisible overlay show/hide/toggle works -- Subtitle behavior: - - subtitle updates still render - - copy/mine shortcuts still function -- Integration: - - runtime options palette opens - - texthooker mode serves UI and can be opened - -## Extraction Rules - -- Move code verbatim first, refactor internals second. -- Keep temporary adapters/shims in `main.ts` until parity is verified. -- Limit each PR to one subsystem/risk area. -- If a regression appears, revert only that extraction slice and keep prior working structure. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..65114bb --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,189 @@ +# Troubleshooting + +Common issues and how to resolve them. + +## MPV Connection + +**Overlay starts but shows no subtitles** + +SubMiner connects to mpv via a Unix socket (or named pipe on Windows). If the socket does not exist or the path does not match, the overlay will appear but subtitles will never arrive. + +- Ensure mpv is running with `--input-ipc-server=/tmp/subminer-socket`. +- If you use a custom socket path, set it in both your mpv config and SubMiner config (`mpvSocketPath`). +- The `subminer` wrapper script sets the socket automatically when it launches mpv. If you launch mpv yourself, the `--input-ipc-server` flag is required. + +SubMiner retries the connection automatically with increasing delays (200 ms, 500 ms, 1 s, 2 s on first connect; 1 s, 2 s, 5 s, 10 s on reconnect). If mpv exits and restarts, the overlay reconnects without needing a restart. + +**"Failed to parse MPV message"** + +Logged when a malformed JSON line arrives from the mpv socket. Usually harmless — SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path. + +## AnkiConnect + +**"AnkiConnect: unable to connect"** + +SubMiner polls AnkiConnect at `http://127.0.0.1:8765` (configurable via `ankiConnect.url`). This error means Anki is not running or the AnkiConnect add-on is not installed. + +- Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on in Anki. +- Make sure Anki is running before you start mining. +- If you changed the AnkiConnect port, update `ankiConnect.url` in your config. + +SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated error logs after 5 consecutive failures. When Anki comes back, you will see "AnkiConnect connection restored". + +**Cards are created but fields are empty** + +Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` — for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated. + +See [Anki Integration](/anki-integration) for the full field mapping reference. + +**"Update failed" OSD message** + +Shown when SubMiner tries to update a card that no longer exists, or when AnkiConnect rejects the update. Common causes: + +- The card was deleted in Anki between polling and update. +- The note type changed and a mapped field no longer exists. + +## Overlay + +**Overlay does not appear** + +- Confirm SubMiner is running: `SubMiner.AppImage --start` or check for the process. +- On Linux, the overlay requires a compositor. Hyprland and Sway are supported natively; X11 requires `xdotool` and `xwininfo`. +- On macOS, grant Accessibility permission to SubMiner in System Settings > Privacy & Security > Accessibility. + +**Overlay appears but clicks pass through / cannot interact** + +- On Linux, mouse passthrough can be unreliable — this is a known Electron/platform limitation. The overlay keeps pointer events enabled by default on Linux. +- On macOS/Windows, `setIgnoreMouseEvents` toggles automatically. If clicks stop working, toggle the overlay off and back on (`Alt+Shift+O`). +- Make sure you are hovering over the subtitle area — the overlay only becomes interactive when the cursor is over subtitle text. + +**Overlay is on the wrong monitor or position** + +SubMiner positions the overlay by tracking the mpv window. If tracking fails: + +- Hyprland: Ensure `hyprctl` is available. +- Sway: Ensure `swaymsg` is available. +- X11: Ensure `xdotool` and `xwininfo` are installed. + +If the overlay position is slightly off, use invisible subtitle position edit mode (`Ctrl/Cmd+Shift+P`) to fine-tune the offset with arrow keys, then save with `Enter` or `Ctrl+S`. + +## Yomitan + +**"Yomitan extension not found in any search path"** + +SubMiner bundles Yomitan and searches for it in these locations (in order): + +1. `vendor/yomitan` (relative to executable) +2. `/yomitan` (Electron resources path) +3. `/usr/share/SubMiner/yomitan` +4. `~/.config/SubMiner/extensions/yomitan` + +If you installed from the AppImage and see this error, the package may be incomplete. Re-download the AppImage or place the Yomitan extension manually in `~/.config/SubMiner/extensions/yomitan`. + +**Yomitan popup does not appear when clicking words** + +- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension". +- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported. +- If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below. + +## MeCab / Tokenization + +**"MeCab not found on system"** + +This is informational, not an error. SubMiner uses Yomitan's internal parser as the primary tokenizer and falls back to MeCab when needed. If MeCab is not installed, Yomitan handles all tokenization. + +To install MeCab: + +- **Arch Linux**: `sudo pacman -S mecab mecab-ipadic` +- **Ubuntu/Debian**: `sudo apt install mecab libmecab-dev mecab-ipadic-utf8` +- **macOS**: `brew install mecab mecab-ipadic` + +**Words are not segmented correctly** + +Japanese word boundaries depend on the tokenizer. If segmentation seems wrong: + +- Install MeCab for improved accuracy as a fallback. +- Note that CJK characters without spaces are segmented using `Intl.Segmenter` or character-level fallback, which is not always perfect. + +## Media Generation + +**"FFmpeg not found"** + +SubMiner uses FFmpeg to extract audio clips and generate screenshots. Install it: + +- **Arch Linux**: `sudo pacman -S ffmpeg` +- **Ubuntu/Debian**: `sudo apt install ffmpeg` +- **macOS**: `brew install ffmpeg` + +Without FFmpeg, card creation still works but audio and image fields will be empty. + +**Audio or screenshot generation hangs** + +Media generation has a 30-second timeout (60 seconds for animated AVIF). If your video file is on a slow network mount or the codec requires software decoding, generation may time out. Try: + +- Using a local copy of the video file. +- Reducing `media.imageQuality` or switching from `avif` to `static` image type. +- Checking that `media.maxMediaDuration` is not set too high. + +## Shortcuts + +**"Failed to register global shortcut"** + +Global shortcuts (`Alt+Shift+O`, `Alt+Shift+I`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings. + +- Check your DE/WM keybinding settings for conflicts. +- Change the shortcuts in your config under `shortcuts.toggleVisibleOverlayGlobal`, `shortcuts.toggleInvisibleOverlayGlobal`. +- On Wayland, global shortcut registration has limitations depending on the compositor. + +**Overlay keybindings not working** + +Overlay-local shortcuts (Space, arrow keys, etc.) only work when the overlay window has focus. Click on the overlay or use the global shortcut to toggle it to give it focus. + +## Subtitle Timing + +**"Subtitle timing not found; copy again while playing"** + +This OSD message appears when you try to mine a sentence but SubMiner has no timing data for the current subtitle. Causes: + +- The video is paused and no subtitle has been received yet. +- The subtitle track changed and timing data was cleared. +- You are using an external subtitle file that mpv has not fully loaded. + +Resume playback and wait for the next subtitle to appear, then try mining again. + +## Subtitle Sync (Subsync) + +**"Configured alass executable not found"** + +Install alass or configure the path: + +- **Arch Linux (AUR)**: `yay -S alass-git` +- Set the path: `subsync.alass_path` in your config. + +**"Subtitle synchronization failed"** + +SubMiner tries alass first, then falls back to ffsubsync. If both fail: + +- Ensure the reference subtitle track exists in the video (alass requires a source track). +- Check that `ffmpeg` is available (used to extract the internal subtitle track). +- Try running the sync tool manually to see detailed error output. + +## Jimaku + +**"Jimaku request failed" or HTTP 429** + +The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits. + +## Platform-Specific + +### Linux + +- **Wayland (Hyprland/Sway)**: Window tracking uses compositor-specific commands. If `hyprctl` or `swaymsg` are not on `PATH`, tracking will fail silently. +- **X11**: Requires `xdotool` and `xwininfo`. If missing, the overlay cannot track the mpv window position. +- **Mouse passthrough**: On Linux, Electron's mouse passthrough is unreliable. SubMiner keeps pointer events enabled, meaning you may need to toggle the overlay off to interact with mpv controls underneath. + +### macOS + +- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility. +- **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust the invisible subtitle offset. +- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app` diff --git a/investigation.md b/investigation.md deleted file mode 100644 index 4266383..0000000 --- a/investigation.md +++ /dev/null @@ -1,219 +0,0 @@ -# Refactoring Investigation Report - -## Overview - -This report evaluates the SubMiner refactoring effort on the `refactor` branch against the plan defined in `plan.md` and the safety checklist in `docs/refactor-main-checklist.md`. The refactoring aimed to eliminate unnecessary abstraction layers, consolidate related services, fix known bugs, and add test coverage. - -**Result**: 65 commits, 104 files changed, main.ts reduced from ~5,800 to 1,398 lines, all 67 tests passing, build succeeds. - ---- - -## Phase 1: Delete Thin Wrappers — COMPLETE - -All 9 target wrapper services and 7 associated test files have been deleted and their logic inlined into callers (mostly main.ts or the services they fed). - -| Target File | Status | -|-------------|--------| -| `config-warning-runtime-service.ts` + test | Deleted, inlined | -| `overlay-modal-restore-service.ts` + test | Deleted, inlined | -| `runtime-options-manager-runtime-service.ts` + test | Deleted, inlined | -| `app-logging-runtime-service.ts` + test | Deleted, inlined | -| `overlay-send-service.ts` (no test) | Deleted, inlined | -| `startup-resource-runtime-service.ts` + test | Deleted, inlined | -| `config-generation-runtime-service.ts` + test | Deleted, inlined | -| `app-shutdown-runtime-service.ts` + test | Deleted, inlined | -| `shortcut-ui-deps-runtime-service.ts` + test | Deleted, inlined | - -**Files removed**: 16 (9 services + 7 tests) -**No issues found.** - ---- - -## Phase 2: Consolidate DI Adapter Services — COMPLETE - -All 4 dependency-injection adapter services have been merged into the services they fed, and their test files removed. - -| Adapter | Merged Into | Status | -|---------|------------|--------| -| `cli-command-deps-runtime-service.ts` + test | `cli-command-service.ts` | Done | -| `ipc-deps-runtime-service.ts` + test | `ipc-service.ts` | Done | -| `tokenizer-deps-runtime-service.ts` + test | `tokenizer-service.ts` | Done | -| `app-lifecycle-deps-runtime-service.ts` + test | `app-lifecycle-service.ts` | Done | - -**Files removed**: 8 (4 services + 4 tests) -**No issues found.** - ---- - -## Phase 3: Consolidate Related Services — COMPLETE - -All planned service merges have been executed. - -| Consolidation | Source Files | Result File | Status | -|--------------|-------------|-------------|--------| -| Overlay visibility | `overlay-visibility-runtime-service.ts` | `overlay-visibility-service.ts` | Done | -| Overlay broadcast | `overlay-broadcast-runtime-service.ts` + test | `overlay-manager-service.ts` | Done | -| Overlay shortcuts | `overlay-shortcut-lifecycle-service.ts`, `overlay-shortcut-fallback-runner.ts` | `overlay-shortcut-service.ts` + `overlay-shortcut-handler.ts` | Done | -| Numeric shortcuts | `numeric-shortcut-runtime-service.ts` + test, `numeric-shortcut-session-service.ts` | `numeric-shortcut-service.ts` | Done | -| Startup/bootstrap | `startup-bootstrap-runtime-service.ts` + test, `app-ready-runtime-service.ts` + test | `startup-service.ts` | Done | - -**Files removed**: ~10 -**No issues found.** - ---- - -## Phase 4: Fix Bugs and Code Quality — COMPLETE - -### 4.1 Debug console.log statements -**Status**: RESOLVED — No debug `console.log` or `console.warn` calls remain in `overlay-visibility-service.ts`. - -### 4.2 `as never` type cast -**Status**: RESOLVED — No `as never` cast remains in `tokenizer-service.ts`. The type mismatch was fixed properly. - -### 4.3 fieldGroupingResolver race condition -**Status**: RESOLVED — Fixed in `main.ts` (lines 305–332) with a sequence counter mechanism. Each new resolver increments `fieldGroupingResolverSequence`, and wrapped resolvers check if their sequence matches the current value before executing. Stale resolutions are correctly ignored. - -### 4.4 Async callback safety -**Status**: RESOLVED -- **CLI commands** (`cli-command-service.ts`): Async commands use `runAsyncWithOsd` helper (lines 177–187) which catches errors, logs them, and displays via MPV OSD. -- **IPC handlers** (`ipc-service.ts`): Async handlers use `ipcMain.handle` (not `.on`), which properly awaits and propagates promise results. Synchronous handlers correctly use `ipcMain.on`. - -### 4.5 `-runtime-service` naming convention -**Status**: RESOLVED — No files with `-runtime-service` in the name exist under `src/core/services/`. All have been renamed to `*-service.ts`. - ---- - -## Phase 5: Add Tests for Critical Untested Services — COMPLETE - -Tests added per plan: - -| Service | Test File | Tests | -|---------|-----------|-------| -| `mpv-service.ts` (761 lines) | `mpv-service.test.ts` | Socket protocol, property changes, reconnection | -| `subsync-service.ts` (427 lines) | `subsync-service.test.ts` | Config resolution, command construction, error handling | -| `tokenizer-service.ts` (305 lines) | `tokenizer-service.test.ts` | Parser init, token extraction, edge cases | -| `cli-command-service.ts` (204 lines) | `cli-command-service.test.ts` (290 lines) | Expanded: all dispatch paths, error propagation | - -**Total**: 67 tests passing across all test suites. - ---- - -## Phase 6: Directory Restructure — NOT STARTED (Optional) - -Services remain in the flat `src/core/services/` directory. This phase was explicitly marked optional and low-priority. The current flat structure with ~25 files is manageable. - ---- - -## Checklist Compliance (docs/refactor-main-checklist.md) - -### Invariants - -| Invariant | Status | -|-----------|--------| -| CLI flags and aliases working | Verified via tests | -| IPC channel names backward-compatible | No channel names changed | -| Overlay toggle behavior preserved | Logic moved verbatim | -| MPV integration behavior preserved | Logic moved verbatim, tested | -| Texthooker mode preserved | Not altered | -| Mining/runtime options paths preserved | Logic moved verbatim | - -### Automated Checks - -| Check | Status | -|-------|--------| -| `pnpm run build` | Passes | -| `pnpm run test:core` | 67/67 passing | - -### Manual Smoke Checks - -**Status**: NOT YET PERFORMED — Visual overlay rendering, card mining flow, and field-grouping interaction require a real desktop session with MPV. Automated tests validate logic correctness but cannot catch rendering regressions. - ---- - -## Loose Ends and Concerns - -### 1. Unused Architectural Scaffolding (~500 lines, dead code) - -The following files exist in the repository but are **not imported or used anywhere**: - -**Core abstractions:** -| File | Lines | Purpose | -|------|-------|---------| -| `src/core/action-bus.ts` | 22 | Generic action dispatcher (`register`/`dispatch`) | -| `src/core/actions.ts` | 17 | Union type `AppAction` with 16 action variants | -| `src/core/app-context.ts` | 46 | Module context interfaces | -| `src/core/module-registry.ts` | 37 | Module lifecycle manager (init/start/stop) | -| `src/core/module.ts` | 7 | `SubminerModule` interface | - -**Module implementations:** -| File | Lines | Purpose | -|------|-------|---------| -| `src/modules/runtime-options/module.ts` | 62 | Runtime options module | -| `src/modules/subsync/module.ts` | 79 | Subsync module | -| `src/modules/jimaku/module.ts` | 73 | Jimaku module | - -**IPC abstraction layer:** -| File | Lines | Purpose | -|------|-------|---------| -| `src/ipc/contract.ts` | 62 | IPC channel name constants | -| `src/ipc/main-api.ts` | 20 | Main process IPC helpers | -| `src/ipc/renderer-api.ts` | 28 | Renderer process IPC helpers | - -These represent scaffolding for a module-based architecture that was prototyped but never wired into main.ts. The current codebase uses the service-oriented approach throughout. - -**Recommendation**: Remove if abandoned, or document with clear intent if planned for a future phase. - -### 2. New Consolidated Services Without Test Coverage - -Seven service files created or consolidated during Phase 3 lack dedicated tests: - -| File | Lines | Risk Level | Reason | -|------|-------|------------|--------| -| `overlay-shortcut-handler.ts` | 216 | Higher | Complex runtime handlers with fallback logic | -| `mining-service.ts` | 179 | Higher | 6 public functions orchestrating mining workflows | -| `anki-jimaku-service.ts` | 173 | Higher | Complex IPC registration with multiple handlers | -| `startup-service.ts` | ~150 | Medium | Bootstrap and app-ready orchestration | -| `numeric-shortcut-service.ts` | 133 | Medium | Session state management | -| `subsync-runner-service.ts` | 86 | Lower | Thin runtime wrapper | -| `jimaku-service.ts` | 81 | Lower | Config accessor functions | - -Phase 5 targeted the 4 highest-risk *previously existing* untested services. The Phase 3 consolidation created new files that inherited logic from multiple sources — those were not explicitly called out in the plan for testing. - -### 3. Null Safety in Consolidated Services - -All checked consolidated services handle null/undefined correctly: - -- **`mpv-control-service.ts`**: Accepts `MpvRuntimeClientLike | null`, uses null checks and optional chaining before all access. -- **`overlay-bridge-service.ts`**: Returns `false` early if window is null or destroyed (`line 17: if (!options.mainWindow || options.mainWindow.isDestroyed()) return false`). -- **`startup-service.ts`**: Uses dependency injection with all deps provided upfront, async operations awaited in sequence, config access uses optional chaining. - -**No null safety issues found.** - -### 4. Import Consistency - -All 68 imports in `main.ts` from `./core/services` resolve to existing exports. No references to deleted files remain. The barrel export in `index.ts` has 79 entries with no dead exports (one minor case: `isGlobalShortcutRegisteredSafe` is only used within the service layer itself, not by main.ts). - ---- - -## Risk Assessment - -| Area | Risk | Mitigation | -|------|------|------------| -| Logic regression from inlining | Low | Code moved verbatim, 67 tests pass | -| Overlay rendering regression | Medium | Requires manual smoke test with MPV | -| Unused scaffolding becoming stale | Low | Remove or document with clear intent | -| Missing test coverage on new files | Medium | Add tests for the 3 higher-risk services | -| Race conditions | Low | fieldGroupingResolver race fixed with sequence counter | -| Async error swallowing | Low | All async paths have error boundaries | - ---- - -## Recommendations - -1. **Remove unused scaffolding** (`src/core/action-bus.ts`, `src/core/actions.ts`, `src/core/app-context.ts`, `src/core/module-registry.ts`, `src/core/module.ts`, `src/modules/`, `src/ipc/`) — ~500 lines of dead code that contradicts the refactoring goal of reducing unnecessary abstraction. - -2. **Add tests for higher-risk consolidated services** — `overlay-shortcut-handler.ts`, `mining-service.ts`, and `anki-jimaku-service.ts` have the most complex logic among the untested new files. - -3. **Perform desktop smoke test** — Verify overlay rendering, card mining, and field-grouping interaction in a real session with MPV running. - -4. **Consider removing `isGlobalShortcutRegisteredSafe` from barrel export** — It's only used internally by the service layer, not by main.ts. diff --git a/plan.md b/plan.md deleted file mode 100644 index b861797..0000000 --- a/plan.md +++ /dev/null @@ -1,377 +0,0 @@ -# SubMiner Refactoring Plan - -## Goals - -- Eliminate unnecessary abstraction layers and thin wrapper services -- Consolidate related services into cohesive domain modules -- Fix known bugs and code quality issues -- Add test coverage for critical untested services -- Keep main.ts lean without pushing complexity into pointless indirection - -## Guiding Principles - -- **Verify after every phase**: `pnpm run build && pnpm run test:config && pnpm run test:core` must pass -- **One concern per commit**: each commit should be a single logical change (can be multiple files as long as it makes sense logically) -- **Inline first, restructure second**: delete the wrapper, verify nothing breaks, then clean up -- **Don't create new abstractions**: the goal is fewer files, not different files - ---- - -## Phase 1: Delete Thin Wrappers (9 files + 7 test files) - -These services wrap a single function call or trivial operation. Inline their logic -into callers (mostly main.ts or the service that calls them) and delete both the -service file and its test file. - -### 1.1 Inline `config-warning-runtime-service.ts` (14 lines) - -Two pure string-formatting functions. Inline the format string directly where -`logConfigWarningRuntimeService` is called (should be in main.ts startup or -`app-logging-runtime-service.ts`). Delete both files. - -- Delete: `config-warning-runtime-service.ts`, `config-warning-runtime-service.test.ts` - -### 1.2 Inline `overlay-modal-restore-service.ts` (18 lines) - -Wraps `Set.add()` and a conditional `Set.delete()`. Inline the Set operations at -call sites in main.ts. - -- Delete: `overlay-modal-restore-service.ts`, `overlay-modal-restore-service.test.ts` - -### 1.3 Inline `runtime-options-manager-runtime-service.ts` (17 lines) - -Wraps `new RuntimeOptionsManager(...)`. Call the constructor directly. - -- Delete: `runtime-options-manager-runtime-service.ts`, `runtime-options-manager-runtime-service.test.ts` - -### 1.4 Inline `app-logging-runtime-service.ts` (28 lines) - -Creates an object with two methods. After inlining config-warning (1.1), this -becomes a trivial object literal. Inline where the logging runtime is created. - -- Delete: `app-logging-runtime-service.ts`, `app-logging-runtime-service.test.ts` - -### 1.5 Inline `overlay-send-service.ts` (26 lines) - -Wraps `window.webContents.send()` with a null/destroyed guard. This is a -one-liner pattern. Inline at call sites. - -- Delete: `overlay-send-service.ts` (no test file) - -### 1.6 Inline `startup-resource-runtime-service.ts` (26 lines) - -Two functions that call a constructor and a check method. Inline into the -app-ready startup flow. - -- Delete: `startup-resource-runtime-service.ts`, `startup-resource-runtime-service.test.ts` - -### 1.7 Inline `config-generation-runtime-service.ts` (26 lines) - -Simple conditional (if args say generate config, call generator, quit). Inline -into the startup bootstrap flow. - -- Delete: `config-generation-runtime-service.ts`, `config-generation-runtime-service.test.ts` - -### 1.8 Inline `app-shutdown-runtime-service.ts` (27 lines) - -Calls 10 cleanup functions in sequence. Inline into the willQuit handler in -main.ts. - -- Delete: `app-shutdown-runtime-service.ts`, `app-shutdown-runtime-service.test.ts` - -### 1.9 Inline `shortcut-ui-deps-runtime-service.ts` (24 lines) - -Single wrapper that unwraps 4 getters and calls `runOverlayShortcutLocalFallback`. -Inline at call site. - -- Delete: `shortcut-ui-deps-runtime-service.ts`, `shortcut-ui-deps-runtime-service.test.ts` - -### Phase 1 verification - -```bash -pnpm run build && pnpm run test:core -``` - -**Expected result**: 16 files deleted, ~260 lines of service code removed, -~350 lines of test code for trivial wrappers removed. `index.ts` barrel export -shrinks by ~10 entries. - ---- - -## Phase 2: Consolidate DI Adapter Services (4 files) - -These are 50-130 line files that map one interface shape to another with minimal -logic. They exist because the service they adapt has a different interface than -what main.ts provides. The fix is to align the interfaces so the adapter isn't -needed, or absorb the adapter into the service it feeds. - -### 2.1 Merge `cli-command-deps-runtime-service.ts` into `cli-command-service.ts` - -The deps adapter (132 lines) maps main.ts state into `CliCommandServiceDeps`. -Instead: make `handleCliCommandService` accept the same shape main.ts naturally -provides, or accept a smaller interface with the actual values rather than -getter/setter pairs. Move any null-guarding logic into the command handlers -themselves. - -- Delete: `cli-command-deps-runtime-service.ts`, `cli-command-deps-runtime-service.test.ts` -- Modify: `cli-command-service.ts` to accept deps directly - -### 2.2 Merge `ipc-deps-runtime-service.ts` into `ipc-service.ts` - -Same pattern as 2.1. The deps adapter (100 lines) maps main.ts state for IPC -handlers. Merge the defensive null checks and window guards into the IPC handlers -that need them. - -- Delete: `ipc-deps-runtime-service.ts`, `ipc-deps-runtime-service.test.ts` -- Modify: `ipc-service.ts` - -### 2.3 Merge `tokenizer-deps-runtime-service.ts` into `tokenizer-service.ts` - -The adapter (45 lines) has one non-trivial function (`tokenizeWithMecab` with null -checks and token merging). Move that logic into `tokenizer-service.ts`. - -- Delete: `tokenizer-deps-runtime-service.ts`, `tokenizer-deps-runtime-service.test.ts` -- Modify: `tokenizer-service.ts` - -### 2.4 Merge `app-lifecycle-deps-runtime-service.ts` into `app-lifecycle-service.ts` - -The adapter (57 lines) wraps Electron app events. Merge event binding into the -lifecycle service itself since it already knows about Electron's app lifecycle. - -- Delete: `app-lifecycle-deps-runtime-service.ts`, `app-lifecycle-deps-runtime-service.test.ts` -- Modify: `app-lifecycle-service.ts` - -### Phase 2 verification - -```bash -pnpm run build && pnpm run test:core -``` - -**Expected result**: 8 more files deleted. DI adapters absorbed into the services -they feed. `index.ts` shrinks further. - ---- - -## Phase 3: Consolidate Related Services - -Merge services that are split across multiple files but represent a single concern. - -### 3.1 Merge overlay visibility files - -`overlay-visibility-runtime-service.ts` (46 lines) is a thin orchestration layer -over `overlay-visibility-service.ts` (183 lines). Merge into one file: -`overlay-visibility-service.ts`. - -- Delete: `overlay-visibility-runtime-service.ts` -- Modify: `overlay-visibility-service.ts` — absorb the 3 exported functions - -### 3.2 Merge overlay broadcast files - -`overlay-broadcast-runtime-service.ts` (45 lines) contains utility functions for -window filtering and broadcasting. These are closely related to -`overlay-manager-service.ts` (49 lines) which manages the window references. -Merge broadcast functions into the overlay manager since it already owns the -window state. - -- Delete: `overlay-broadcast-runtime-service.ts`, `overlay-broadcast-runtime-service.test.ts` -- Modify: `overlay-manager-service.ts` — add broadcast methods - -### 3.3 Merge overlay shortcut files - -There are 4 shortcut-related files: - -- `overlay-shortcut-service.ts` (169 lines) — registration -- `overlay-shortcut-runtime-service.ts` (105 lines) — runtime handlers -- `overlay-shortcut-lifecycle-service.ts` (52 lines) — sync/refresh/unregister -- `overlay-shortcut-fallback-runner.ts` (114 lines) — local fallback execution - -Consolidate into 2 files: - -- `overlay-shortcut-service.ts` — registration + lifecycle (absorb lifecycle-service) -- `overlay-shortcut-handler.ts` — runtime handlers + fallback runner - -- Delete: `overlay-shortcut-lifecycle-service.ts`, `overlay-shortcut-fallback-runner.ts` - -### 3.4 Merge numeric shortcut files - -`numeric-shortcut-runtime-service.ts` (37 lines) and -`numeric-shortcut-session-service.ts` (99 lines) are two halves of one feature. -Merge into `numeric-shortcut-service.ts`. - -- Delete: `numeric-shortcut-runtime-service.ts`, `numeric-shortcut-runtime-service.test.ts` -- Modify: `numeric-shortcut-session-service.ts` → rename to `numeric-shortcut-service.ts` - -### 3.5 Merge startup/bootstrap files - -`startup-bootstrap-runtime-service.ts` (53 lines) and -`app-ready-runtime-service.ts` (77 lines) are both startup orchestration. -Merge into a single `startup-service.ts`. - -- Delete: `startup-bootstrap-runtime-service.ts`, `startup-bootstrap-runtime-service.test.ts` -- Modify: `app-ready-runtime-service.ts` → rename to `startup-service.ts`, absorb bootstrap - -### Phase 3 verification - -```bash -pnpm run build && pnpm run test:core -``` - -**Expected result**: ~10 more files deleted. Related services consolidated into -single cohesive modules. - ---- - -## Phase 4: Fix Bugs and Code Quality - -### 4.1 Remove debug `console.log` statements - -`overlay-visibility-service.ts` has 7+ raw `console.log`/`console.warn` calls -used for debugging. Remove them or replace with the app logger if the messages -have ongoing diagnostic value. - -### 4.2 Fix `as never` type cast - -`tokenizer-deps-runtime-service.ts` (or its successor after Phase 2) uses -`return mergeTokens(rawTokens as never)`. Investigate the actual type mismatch -and fix it properly. - -### 4.3 Guard `fieldGroupingResolver` against race conditions - -In main.ts, the `fieldGroupingResolver` is a single global variable. If two -field grouping requests arrive concurrently, the second overwrites the first's -resolver. Add a request ID or sequence number so stale resolutions are ignored. - -### 4.4 Audit async callbacks in CLI command handlers - -Verify that async functions passed as callbacks in the CLI command and IPC handler -wiring (main.ts lines ~697-707, ~1347, ~1360) are properly awaited or have -`.catch()` handlers so rejections aren't silently swallowed. - -### 4.5 Drop the `-runtime-service` naming convention - -After phases 1-3, rename remaining files to just `*-service.ts`. The "runtime" -prefix adds no meaning. Do this as a batch rename commit with no logic changes. - -### Phase 4 verification - -```bash -pnpm run build && pnpm run test:core -``` - -Manually smoke test: launch SubMiner, verify overlay renders, mine a card, toggle -field grouping. - ---- - -## Phase 5: Add Tests for Critical Untested Services - -These are the highest-risk untested modules. Add focused tests that verify -real behavior, not just that mocks were called. - -### 5.1 `mpv-service.ts` (761 lines, untested) - -Test the IPC protocol layer: - -- Socket message parsing (JSON line protocol) -- Property change event dispatch -- Request/response matching via request IDs -- Reconnection behavior on socket close -- Subtitle text extraction from property changes - -### 5.2 `subsync-service.ts` (427 lines, untested) - -Test: - -- Config resolution (alass vs ffsubsync path selection) -- Command construction for each sync engine -- Timeout and error handling for child processes -- Result parsing - -### 5.3 `tokenizer-service.ts` (305 lines, untested) - -Test: - -- Yomitan parser initialization and readiness -- Token extraction from parsed results -- Fallback behavior when parser unavailable -- Edge cases: empty text, CJK-only, mixed content - -### 5.4 `cli-command-service.ts` (204 lines, partially tested) - -Expand existing tests to cover: - -- All CLI command dispatch paths -- Error propagation from async handlers -- Second-instance argument forwarding - -### Phase 5 verification - -```bash -pnpm run test:core -``` - -All new tests pass. Aim for the 4 services above to each have 5-10 meaningful -test cases. - ---- - -## Phase 6 (Optional): Domain-Based Directory Structure - -After phases 1-5, the service count should be roughly 20-25 files down from 47. -If that still feels too flat, group by domain: - -``` -src/core/ - mpv/ - mpv-service.ts - mpv-render-metrics-service.ts - overlay/ - overlay-manager-service.ts - overlay-visibility-service.ts - overlay-window-service.ts - overlay-shortcut-service.ts - overlay-shortcut-handler.ts - mining/ - mining-runtime-service.ts - field-grouping-service.ts - field-grouping-overlay-service.ts - startup/ - startup-service.ts - app-lifecycle-service.ts - ipc/ - ipc-service.ts - ipc-command-service.ts - shortcuts/ - shortcut-service.ts - numeric-shortcut-service.ts - services/ - tokenizer-service.ts - subtitle-position-service.ts - subtitle-ws-service.ts - texthooker-service.ts - yomitan-extension-loader-service.ts - yomitan-settings-service.ts - secondary-subtitle-service.ts - runtime-config-service.ts - runtime-options-runtime-service.ts - cli-command-service.ts -``` - -Only do this if the flat directory still feels unwieldy after consolidation. -This is cosmetic and low-priority relative to phases 1-5. - ---- - -## Summary - -| Phase | Files Deleted | Files Modified | Risk | Effort | -| -------------------------- | ------------------- | ----------------- | ------ | ------ | -| 1. Delete thin wrappers | 16 (9 svc + 7 test) | main.ts, index.ts | Low | Small | -| 2. Consolidate DI adapters | 8 (4 svc + 4 test) | 4 services | Medium | Medium | -| 3. Merge related services | ~10 | ~5 services | Medium | Medium | -| 4. Fix bugs & rename | 0 | ~6 files | Low | Small | -| 5. Add critical tests | 0 | 4 new test files | Low | Medium | -| 6. Directory restructure | 0 | All imports | Low | Small | - -**Net result**: ~34 files deleted, service count from 47 → ~22, index.ts from -92 exports → ~45, and the remaining services each have a clear reason to exist. From bba1bd554e07c12d0c2fdb170e5b6e8bf2f0c95f Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 11 Feb 2026 00:39:43 -0800 Subject: [PATCH 19/74] update backlog --- .../m-0 - codebase-clarity-&-composability.md | 8 +++ ...e-naming-conventions-and-barrel-exports.md | 40 ++++++++++++++ ...titleLayoutFromMpvMetrics-mega-function.md | 38 +++++++++++++ ...undling-for-multi-file-renderer-support.md | 41 ++++++++++++++ ...ermaid-diagrams-in-docs-for-readability.md | 30 ++++++++++ ...cation-between-renderer.ts-and-types.ts.md | 37 +++++++++++++ ... Split-renderer.ts-into-focused-modules.md | 55 +++++++++++++++++++ ...global-state-into-an-AppState-container.md | 35 ++++++++++++ ...eparate-protocol-from-application-logic.md | 38 +++++++++++++ ...-trivial-wrapper-functions-from-main.ts.md | 47 ++++++++++++++++ 10 files changed, 369 insertions(+) create mode 100644 backlog/milestones/m-0 - codebase-clarity-&-composability.md create mode 100644 backlog/tasks/task-10 - Consolidate-service-naming-conventions-and-barrel-exports.md create mode 100644 backlog/tasks/task-11 - Break-up-the-applyInvisibleSubtitleLayoutFromMpvMetrics-mega-function.md create mode 100644 backlog/tasks/task-12 - Add-renderer-module-bundling-for-multi-file-renderer-support.md create mode 100644 backlog/tasks/task-4 - Improve-Mermaid-diagrams-in-docs-for-readability.md create mode 100644 backlog/tasks/task-5 - Eliminate-type-duplication-between-renderer.ts-and-types.ts.md create mode 100644 backlog/tasks/task-6 - Split-renderer.ts-into-focused-modules.md create mode 100644 backlog/tasks/task-7 - Extract-main.ts-global-state-into-an-AppState-container.md create mode 100644 backlog/tasks/task-8 - Reduce-MpvIpcClient-deps-interface-and-separate-protocol-from-application-logic.md create mode 100644 backlog/tasks/task-9 - Remove-trivial-wrapper-functions-from-main.ts.md diff --git a/backlog/milestones/m-0 - codebase-clarity-&-composability.md b/backlog/milestones/m-0 - codebase-clarity-&-composability.md new file mode 100644 index 0000000..bfb2d13 --- /dev/null +++ b/backlog/milestones/m-0 - codebase-clarity-&-composability.md @@ -0,0 +1,8 @@ +--- +id: m-0 +title: "Codebase Clarity & Composability" +--- + +## Description + +Improvements to code clarity, simplicity, and composability identified during the Feb 2026 codebase review. Focus on reducing monolithic files, eliminating duplication, and improving architectural boundaries. diff --git a/backlog/tasks/task-10 - Consolidate-service-naming-conventions-and-barrel-exports.md b/backlog/tasks/task-10 - Consolidate-service-naming-conventions-and-barrel-exports.md new file mode 100644 index 0000000..aa2b7e6 --- /dev/null +++ b/backlog/tasks/task-10 - Consolidate-service-naming-conventions-and-barrel-exports.md @@ -0,0 +1,40 @@ +--- +id: TASK-10 +title: Consolidate service naming conventions and barrel exports +status: To Do +assignee: [] +created_date: '2026-02-11 08:21' +labels: + - refactor + - services + - naming +milestone: Codebase Clarity & Composability +dependencies: [] +references: + - src/core/services/index.ts +priority: low +--- + +## Description + + +The service layer has inconsistent naming: +- Some functions end in `Service`: `handleCliCommandService`, `loadSubtitlePositionService` +- Some end in `RuntimeService`: `replayCurrentSubtitleRuntimeService`, `sendMpvCommandRuntimeService` +- Some are plain: `shortcutMatchesInputForLocalFallback` +- Factory functions mix `create*DepsRuntimeService` with `create*Service` + +The barrel export (src/core/services/index.ts) re-exports 79 symbols from 28 files through a single surface, which obscures dependency boundaries. Consumers import everything from `./core/services` and can't tell which service file they actually depend on. + +Establish consistent naming: +- Exported service functions: `verbNounService` (e.g., `handleCliCommand`) +- Deps factory functions: `create*Deps` +- Consider whether the barrel re-export is still the right pattern vs direct imports from individual files. + + +## Acceptance Criteria + +- [ ] #1 All service functions follow a consistent naming convention +- [ ] #2 Decision documented on barrel export vs direct imports +- [ ] #3 No functional changes + diff --git a/backlog/tasks/task-11 - Break-up-the-applyInvisibleSubtitleLayoutFromMpvMetrics-mega-function.md b/backlog/tasks/task-11 - Break-up-the-applyInvisibleSubtitleLayoutFromMpvMetrics-mega-function.md new file mode 100644 index 0000000..9979863 --- /dev/null +++ b/backlog/tasks/task-11 - Break-up-the-applyInvisibleSubtitleLayoutFromMpvMetrics-mega-function.md @@ -0,0 +1,38 @@ +--- +id: TASK-11 +title: Break up the applyInvisibleSubtitleLayoutFromMpvMetrics mega function +status: To Do +assignee: [] +created_date: '2026-02-11 08:21' +labels: + - refactor + - renderer + - complexity +milestone: Codebase Clarity & Composability +dependencies: [] +references: + - src/renderer/renderer.ts +priority: medium +--- + +## Description + + +In renderer.ts (around lines 865-1075), `applyInvisibleSubtitleLayoutFromMpvMetrics` is a 211-line function with up to 5 levels of nesting. It handles OSD scaling calculations, platform-specific font compensation (macOS vs Linux), DPR calculations, ASS alignment tag interpretation (\an tags), baseline compensation, line-height fixes, font property application, and transform origin — all interleaved. + +Extract into focused helpers: +- `calculateOsdScale(metrics, renderAreaHeight)` — pure scaling math +- `calculateSubtitlePosition(metrics, scale, alignment)` — ASS \an tag interpretation + positioning +- `applyPlatformFontCompensation(style, platform)` — macOS kerning/size adjustments +- `applySubtitleStyle(element, computedStyle)` — DOM style application + +This can be done independently of or as part of TASK-6 (renderer split). + + +## Acceptance Criteria + +- [ ] #1 No single function exceeds ~50 lines in the positioning logic +- [ ] #2 Helper functions are pure where possible (take inputs, return outputs) +- [ ] #3 Platform-specific branches isolated into dedicated helpers +- [ ] #4 Invisible overlay positioning still works correctly on Linux and macOS + diff --git a/backlog/tasks/task-12 - Add-renderer-module-bundling-for-multi-file-renderer-support.md b/backlog/tasks/task-12 - Add-renderer-module-bundling-for-multi-file-renderer-support.md new file mode 100644 index 0000000..c80cd53 --- /dev/null +++ b/backlog/tasks/task-12 - Add-renderer-module-bundling-for-multi-file-renderer-support.md @@ -0,0 +1,41 @@ +--- +id: TASK-12 +title: Add renderer module bundling for multi-file renderer support +status: To Do +assignee: [] +created_date: '2026-02-11 08:21' +labels: + - infrastructure + - renderer + - build +milestone: Codebase Clarity & Composability +dependencies: + - TASK-5 +references: + - src/renderer/renderer.ts + - src/renderer/index.html + - package.json + - tsconfig.json +priority: medium +--- + +## Description + + +Currently renderer.ts is a single file loaded directly by Electron's renderer process via a script tag in index.html. To split it into modules (TASK-6), we need a bundling step since Electron renderer's default context doesn't support bare ES module imports without additional configuration. + +Options: +1. **esbuild** — fast, minimal config, already used in many Electron projects +2. **Electron's native ESM support** — requires `"type": "module"` and sandbox configuration +3. **TypeScript compiler output** — if targeting a single concatenated bundle + +The build pipeline already compiles TypeScript and copies renderer assets. Adding a bundling step for the renderer would slot into the existing `npm run build` script. + + +## Acceptance Criteria + +- [ ] #1 Renderer code can be split across multiple .ts files with imports +- [ ] #2 Build pipeline bundles renderer modules into a single output for Electron +- [ ] #3 Existing `make build` still works end-to-end +- [ ] #4 No runtime errors in renderer process + diff --git a/backlog/tasks/task-4 - Improve-Mermaid-diagrams-in-docs-for-readability.md b/backlog/tasks/task-4 - Improve-Mermaid-diagrams-in-docs-for-readability.md new file mode 100644 index 0000000..ffdfc43 --- /dev/null +++ b/backlog/tasks/task-4 - Improve-Mermaid-diagrams-in-docs-for-readability.md @@ -0,0 +1,30 @@ +--- +id: TASK-4 +title: Improve Mermaid diagrams in docs for readability +status: Done +assignee: [] +created_date: '2026-02-11 07:11' +updated_date: '2026-02-11 07:11' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Refine Mermaid charts in documentation (primarily architecture docs) to improve readability, grouping, and label clarity without changing system behavior. + + +## Acceptance Criteria + +- [x] #1 Mermaid diagrams render successfully in VitePress docs build +- [x] #2 Diagrams have clearer grouping, edge labels, and flow direction +- [x] #3 No broken markdown or Mermaid syntax in updated docs + + +## Final Summary + + +Improved Mermaid diagrams in docs/architecture.md by redesigning both flowcharts with clearer subgraphs, labeled edges, and consistent lifecycle/runtime separation. Verified successful rendering via `pnpm run docs:build` with no chunk-size warning regressions. + diff --git a/backlog/tasks/task-5 - Eliminate-type-duplication-between-renderer.ts-and-types.ts.md b/backlog/tasks/task-5 - Eliminate-type-duplication-between-renderer.ts-and-types.ts.md new file mode 100644 index 0000000..e5db9cc --- /dev/null +++ b/backlog/tasks/task-5 - Eliminate-type-duplication-between-renderer.ts-and-types.ts.md @@ -0,0 +1,37 @@ +--- +id: TASK-5 +title: Eliminate type duplication between renderer.ts and types.ts +status: To Do +assignee: [] +created_date: '2026-02-11 08:20' +labels: + - refactor + - types + - renderer +milestone: Codebase Clarity & Composability +dependencies: [] +references: + - src/renderer/renderer.ts + - src/types.ts + - src/main.ts +priority: high +--- + +## Description + + +renderer.ts locally redefines 20+ interfaces/types that already exist in types.ts: MergedToken, SubtitleData, MpvSubtitleRenderMetrics, Keybinding, SubtitlePosition, SecondarySubMode, all Jimaku/Kiku types, RuntimeOption types, SubsyncSourceTrack, SubsyncManualPayload, etc. + +This creates divergence risk — changes in types.ts don't automatically propagate to the renderer's local copies. + +Additionally, `DEFAULT_MPV_SUBTITLE_RENDER_METRICS` and `sanitizeMpvSubtitleRenderMetrics()` exist only in renderer.ts despite being shared concerns (main.ts also has DEFAULT_MPV_SUBTITLE_RENDER_METRICS). + + +## Acceptance Criteria + +- [ ] #1 All shared types are imported from types.ts — no local redefinitions in renderer.ts +- [ ] #2 DEFAULT_MPV_SUBTITLE_RENDER_METRICS lives in one canonical location (types.ts or a shared module) +- [ ] #3 sanitizeMpvSubtitleRenderMetrics moved to a shared module importable by both main and renderer +- [ ] #4 TypeScript compiles cleanly with no type errors +- [ ] #5 Renderer-only types (ChordAction, KikuModalStep, KikuPreviewMode) can stay local + diff --git a/backlog/tasks/task-6 - Split-renderer.ts-into-focused-modules.md b/backlog/tasks/task-6 - Split-renderer.ts-into-focused-modules.md new file mode 100644 index 0000000..da0b56c --- /dev/null +++ b/backlog/tasks/task-6 - Split-renderer.ts-into-focused-modules.md @@ -0,0 +1,55 @@ +--- +id: TASK-6 +title: Split renderer.ts into focused modules +status: To Do +assignee: [] +created_date: '2026-02-11 08:20' +labels: + - refactor + - renderer + - architecture +milestone: Codebase Clarity & Composability +dependencies: + - TASK-5 +references: + - src/renderer/renderer.ts +priority: high +--- + +## Description + + +renderer.ts is 2,754 lines with 94 functions handling 6+ distinct concerns: subtitle rendering, invisible overlay positioning, 4 modal UIs (Jimaku, Kiku, RuntimeOptions, Subsync), event handlers, keyboard chord system, and platform-specific layout. 16+ module-level state variables track overlapping modal states. + +Proposed structure: +``` +src/renderer/ +├── renderer.ts (entry point, initialization, IPC listeners) +├── state.ts (centralized state object replacing 16+ scattered lets) +├── subtitle-render.ts (renderSubtitle, renderTokenized, renderCharLevel, renderPlain) +├── positioning.ts (applyInvisibleSubtitleLayoutFromMpvMetrics + helpers) +├── modals/ +│ ├── jimaku.ts (Jimaku download modal - lines 1097-1518) +│ ├── kiku.ts (Kiku field grouping modal - lines 1519-1702) +│ ├── runtime-options.ts (Runtime options modal - lines 1247-1364) +│ └── subsync.ts (Subsync modal - lines 1387-1466) +├── handlers/ +│ ├── keyboard.ts (keydown handlers, chord system) +│ └── mouse.ts (drag, hover, click handlers) +└── utils/ + ├── dom.ts (DOM element access with validation) + └── platform.ts (isLinux/isMacOS detection, platform-specific helpers) +``` + +Note: The renderer runs in Electron's renderer process, so module bundling considerations (esbuild/webpack or Electron's native ESM) need to be evaluated. + + +## Acceptance Criteria + +- [ ] #1 renderer.ts reduced to <400 lines (init + IPC wiring) +- [ ] #2 Each modal UI in its own module +- [ ] #3 Positioning logic extracted with helper functions replacing the 211-line mega function +- [ ] #4 State centralized in a single object/module +- [ ] #5 Platform-specific logic isolated behind abstractions +- [ ] #6 All existing functionality preserved + diff --git a/backlog/tasks/task-7 - Extract-main.ts-global-state-into-an-AppState-container.md b/backlog/tasks/task-7 - Extract-main.ts-global-state-into-an-AppState-container.md new file mode 100644 index 0000000..a3b544b --- /dev/null +++ b/backlog/tasks/task-7 - Extract-main.ts-global-state-into-an-AppState-container.md @@ -0,0 +1,35 @@ +--- +id: TASK-7 +title: Extract main.ts global state into an AppState container +status: To Do +assignee: [] +created_date: '2026-02-11 08:20' +labels: + - refactor + - main + - architecture +milestone: Codebase Clarity & Composability +dependencies: [] +references: + - src/main.ts +priority: high +--- + +## Description + + +main.ts has 30+ module-level `let` declarations holding all application state: mpvClient, yomitanExt, yomitanSettingsWindow, yomitanParserWindow, reconnectTimer, currentSubText, currentSubAssText, subtitlePosition, currentMediaPath, mecabTokenizer, keybindings, ankiIntegration, secondarySubMode, runtimeOptionsManager, overlayRuntimeInitialized, subsyncInProgress, shortcutsRegistered, etc. + +These are mutated through closures from many different functions, making state changes hard to trace and impossible to test. + +Consolidate into a typed AppState object (or small set of domain-specific state containers) that provides controlled access to state. This also enables passing state to services without building 50-property dependency objects. + + +## Acceptance Criteria + +- [ ] #1 All mutable state consolidated into typed container(s) +- [ ] #2 No bare `let` declarations at module scope for application state +- [ ] #3 State access goes through the container rather than closures +- [ ] #4 Dependency objects for services shrink significantly (reference the container instead) +- [ ] #5 TypeScript compiles cleanly + diff --git a/backlog/tasks/task-8 - Reduce-MpvIpcClient-deps-interface-and-separate-protocol-from-application-logic.md b/backlog/tasks/task-8 - Reduce-MpvIpcClient-deps-interface-and-separate-protocol-from-application-logic.md new file mode 100644 index 0000000..eb84644 --- /dev/null +++ b/backlog/tasks/task-8 - Reduce-MpvIpcClient-deps-interface-and-separate-protocol-from-application-logic.md @@ -0,0 +1,38 @@ +--- +id: TASK-8 +title: >- + Reduce MpvIpcClient deps interface and separate protocol from application + logic +status: To Do +assignee: [] +created_date: '2026-02-11 08:20' +labels: + - refactor + - mpv + - architecture +milestone: Codebase Clarity & Composability +dependencies: [] +references: + - src/core/services/mpv-service.ts +priority: medium +--- + +## Description + + +MpvIpcClient (761 lines) in src/core/services/mpv-service.ts has a 22-property `MpvIpcClientDeps` interface that reaches back into main.ts state for application-level concerns (overlay visibility, subtitle timing, media path updates, OSD display). + +The class mixes two responsibilities: +1. **IPC Protocol**: Socket connection, JSON message framing, reconnection, property observation +2. **Application Integration**: Subtitle text broadcasting, overlay visibility sync, timing tracking + +Separating these would let the protocol layer be simpler and testable, while application-level reactions to mpv events could be handled by listeners/callbacks registered externally. + + +## Acceptance Criteria + +- [ ] #1 MpvIpcClient deps interface reduced to protocol-level concerns only +- [ ] #2 Application-level reactions (subtitle broadcast, overlay sync, timing) handled via event emitter or external listeners +- [ ] #3 MpvIpcClient is testable without mocking 22 callbacks +- [ ] #4 Existing behavior preserved + diff --git a/backlog/tasks/task-9 - Remove-trivial-wrapper-functions-from-main.ts.md b/backlog/tasks/task-9 - Remove-trivial-wrapper-functions-from-main.ts.md new file mode 100644 index 0000000..868abdc --- /dev/null +++ b/backlog/tasks/task-9 - Remove-trivial-wrapper-functions-from-main.ts.md @@ -0,0 +1,47 @@ +--- +id: TASK-9 +title: Remove trivial wrapper functions from main.ts +status: To Do +assignee: [] +created_date: '2026-02-11 08:21' +labels: + - refactor + - main + - simplicity +milestone: Codebase Clarity & Composability +dependencies: + - TASK-7 +references: + - src/main.ts +priority: medium +--- + +## Description + + +main.ts contains many trivial single-line wrapper functions that add indirection without value: + +```typescript +function getOverlayWindows(): BrowserWindow[] { + return overlayManager.getOverlayWindows(); +} +function updateOverlayBounds(geometry: WindowGeometry): void { + updateOverlayBoundsService(geometry, () => getOverlayWindows()); +} +function ensureOverlayWindowLevel(window: BrowserWindow): void { + ensureOverlayWindowLevelService(window); +} +``` + +Similarly, config accessor wrappers like `getJimakuLanguagePreference()`, `getJimakuMaxEntryResults()`, `resolveJimakuApiKey()` are pure boilerplate. + +After TASK-7 (AppState container), many of these can be eliminated by having services access the state container directly, or by using the service functions directly at call sites without local wrappers. + + +## Acceptance Criteria + +- [ ] #1 Trivial pass-through wrappers eliminated (call service/manager directly) +- [ ] #2 Config accessor wrappers replaced with direct calls or a config accessor helper +- [ ] #3 main.ts line count reduced +- [ ] #4 No functional changes + From 1d36409fc70bbf08397a9f71a9545d4fb247b599 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 11 Feb 2026 09:39:59 -0800 Subject: [PATCH 20/74] Update texthooker-ui submodule to subminer branch --- .gitmodules | 1 + vendor/texthooker-ui | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 968da6f..31ab7ff 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "vendor/texthooker-ui"] path = vendor/texthooker-ui url = https://github.com/ksyasuda/texthooker-ui.git + branch = subminer diff --git a/vendor/texthooker-ui b/vendor/texthooker-ui index a6154ab..3c11507 160000 --- a/vendor/texthooker-ui +++ b/vendor/texthooker-ui @@ -1 +1 @@ -Subproject commit a6154ab458d9e80aac7a57f00c4ec58ef655827f +Subproject commit 3c1150740abb768dc7319a19c51644ba3b09e9d7 From ee21c77fd0c86db7750612f5df452e152fbbdadd Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 11 Feb 2026 18:03:57 -0800 Subject: [PATCH 21/74] Fix macOS overlay binding and subtitle alignment --- ...ative-window-bounds-for-overlay-binding.md | 44 +++++ package.json | 9 +- scripts/build-macos-helper.sh | 27 +++ scripts/get-mpv-window-macos.swift | 165 ++++++++++++++++++ src/renderer/renderer.ts | 9 + src/window-trackers/macos-tracker.ts | 153 +++++++++++++--- 6 files changed, 382 insertions(+), 25 deletions(-) create mode 100644 backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md create mode 100755 scripts/build-macos-helper.sh create mode 100644 scripts/get-mpv-window-macos.swift diff --git a/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md b/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md new file mode 100644 index 0000000..f59524f --- /dev/null +++ b/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md @@ -0,0 +1,44 @@ +--- +id: TASK-13 +title: Fix macOS native window bounds for overlay binding +status: Done +assignee: + - codex +created_date: '2026-02-11 15:45' +updated_date: '2026-02-11 16:20' +labels: + - bug + - macos + - overlay +dependencies: [] +references: + - src/window-trackers/macos-tracker.ts + - scripts/get-mpv-window-macos.swift +priority: high +--- + +## Description + + +Overlay windows on macOS are not properly aligned to the mpv window after switching from AppleScript window discovery to native Swift/CoreGraphics bounds retrieval. + +Implement a robust native bounds strategy that prefers Accessibility window geometry (matching app-window coordinates used previously) and falls back to filtered CoreGraphics windows when Accessibility data is unavailable. + + +## Acceptance Criteria + +- [x] #1 Overlay bounds track the active mpv window with correct position and size on macOS. +- [x] #2 Helper avoids selecting off-screen/non-primary mpv-related windows. +- [x] #3 Build succeeds with the updated macOS helper. + + +## Implementation Notes + + +Follow-up in progress after packaged app runtime showed fullscreen fallback behavior: +- Added packaged-app helper path resolution in tracker (`process.resourcesPath/scripts/get-mpv-window-macos`). +- Added `.asar` helper materialization to temp path so child process execution is possible if candidate path resolves inside asar. +- Added throttled tracker logging for helper execution failures to expose runtime errors without log spam. +- Updated Electron builder `extraResources` to ship `dist/scripts/get-mpv-window-macos` outside asar at `resources/scripts/get-mpv-window-macos`. +- Added macOS-only invisible subtitle vertical nudge (`+4px`) in renderer layout to align interactive subtitles with mpv glyph baseline after bounds fix. + diff --git a/package.json b/package.json index dee4887..08a318a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "main": "dist/main.js", "scripts": { - "build": "tsc && cp src/renderer/index.html src/renderer/style.css dist/renderer/", + "build": "tsc && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && bash scripts/build-macos-helper.sh", "check:main-lines": "bash scripts/check-main-lines.sh", "check:main-lines:baseline": "bash scripts/check-main-lines.sh 5300", "check:main-lines:gate1": "bash scripts/check-main-lines.sh 4500", @@ -85,7 +85,8 @@ "dist/**/*", "vendor/texthooker-ui/docs/**/*", "vendor/texthooker-ui/package.json", - "package.json" + "package.json", + "scripts/get-mpv-window-macos.swift" ], "extraResources": [ { @@ -95,6 +96,10 @@ { "from": "assets", "to": "assets" + }, + { + "from": "dist/scripts/get-mpv-window-macos", + "to": "scripts/get-mpv-window-macos" } ] } diff --git a/scripts/build-macos-helper.sh b/scripts/build-macos-helper.sh new file mode 100755 index 0000000..df4c786 --- /dev/null +++ b/scripts/build-macos-helper.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Build macOS window tracking helper binary + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SWIFT_SOURCE="$SCRIPT_DIR/get-mpv-window-macos.swift" +OUTPUT_DIR="$SCRIPT_DIR/../dist/scripts" +OUTPUT_BINARY="$OUTPUT_DIR/get-mpv-window-macos" + +# Only build on macOS +if [[ "$(uname)" != "Darwin" ]]; then + echo "Skipping macOS helper build (not on macOS)" + exit 0 +fi + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Compile Swift script to binary +echo "Compiling macOS window tracking helper..." +swiftc -O "$SWIFT_SOURCE" -o "$OUTPUT_BINARY" + +# Make executable +chmod +x "$OUTPUT_BINARY" + +echo "✓ Built $OUTPUT_BINARY" diff --git a/scripts/get-mpv-window-macos.swift b/scripts/get-mpv-window-macos.swift new file mode 100644 index 0000000..c00e00b --- /dev/null +++ b/scripts/get-mpv-window-macos.swift @@ -0,0 +1,165 @@ +#!/usr/bin/env swift +// +// get-mpv-window-macos.swift +// SubMiner - Get mpv window geometry on macOS +// +// This script uses Core Graphics APIs to find mpv windows system-wide. +// It works with both bundled and unbundled mpv installations. +// +// Usage: swift get-mpv-window-macos.swift +// Output: "x,y,width,height" or "not-found" +// + +import Cocoa + +private struct WindowGeometry { + let x: Int + let y: Int + let width: Int + let height: Int +} + +private func geometryFromRect(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> WindowGeometry { + let minX = Int(floor(x)) + let minY = Int(floor(y)) + let maxX = Int(ceil(x + width)) + let maxY = Int(ceil(y + height)) + return WindowGeometry( + x: minX, + y: minY, + width: max(0, maxX - minX), + height: max(0, maxY - minY) + ) +} + +private func normalizedMpvName(_ name: String) -> Bool { + let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "mpv" +} + +private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? { + var positionRef: CFTypeRef? + var sizeRef: CFTypeRef? + + let positionStatus = AXUIElementCopyAttributeValue(axWindow, kAXPositionAttribute as CFString, &positionRef) + let sizeStatus = AXUIElementCopyAttributeValue(axWindow, kAXSizeAttribute as CFString, &sizeRef) + + guard positionStatus == .success, + sizeStatus == .success, + let positionRaw = positionRef, + let sizeRaw = sizeRef, + CFGetTypeID(positionRaw) == AXValueGetTypeID(), + CFGetTypeID(sizeRaw) == AXValueGetTypeID() else { + return nil + } + + let positionValue = positionRaw as! AXValue + let sizeValue = sizeRaw as! AXValue + + guard AXValueGetType(positionValue) == .cgPoint, + AXValueGetType(sizeValue) == .cgSize else { + return nil + } + + var position = CGPoint.zero + var size = CGSize.zero + + guard AXValueGetValue(positionValue, .cgPoint, &position), + AXValueGetValue(sizeValue, .cgSize, &size) else { + return nil + } + + let geometry = geometryFromRect( + x: position.x, + y: position.y, + width: size.width, + height: size.height + ) + + guard geometry.width >= 100, geometry.height >= 100 else { + return nil + } + + return geometry +} + +private func geometryFromAccessibilityAPI() -> WindowGeometry? { + let runningApps = NSWorkspace.shared.runningApplications.filter { app in + guard let name = app.localizedName else { + return false + } + return normalizedMpvName(name) + } + + for app in runningApps { + let appElement = AXUIElementCreateApplication(app.processIdentifier) + var windowsRef: CFTypeRef? + let status = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) + guard status == .success, let windows = windowsRef as? [AXUIElement], !windows.isEmpty else { + continue + } + + for window in windows { + var minimizedRef: CFTypeRef? + let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef) + if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized { + continue + } + + if let geometry = geometryFromAXWindow(window) { + return geometry + } + } + } + + return nil +} + +private func geometryFromCoreGraphics() -> WindowGeometry? { + // Keep the CG fallback for environments without Accessibility permissions. + // Use on-screen layer-0 windows to avoid off-screen helpers/shadows. + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? [] + + for window in windowList { + guard let ownerName = window[kCGWindowOwnerName as String] as? String, + normalizedMpvName(ownerName) else { + continue + } + + if let layer = window[kCGWindowLayer as String] as? Int, layer != 0 { + continue + } + if let alpha = window[kCGWindowAlpha as String] as? Double, alpha <= 0.01 { + continue + } + if let onScreen = window[kCGWindowIsOnscreen as String] as? Int, onScreen == 0 { + continue + } + + guard let bounds = window[kCGWindowBounds as String] as? [String: CGFloat] else { + continue + } + + let geometry = geometryFromRect( + x: bounds["X"] ?? 0, + y: bounds["Y"] ?? 0, + width: bounds["Width"] ?? 0, + height: bounds["Height"] ?? 0 + ) + + guard geometry.width >= 100, geometry.height >= 100 else { + continue + } + + return geometry + } + + return nil +} + +if let window = geometryFromAccessibilityAPI() ?? geometryFromCoreGraphics() { + print("\(window.x),\(window.y),\(window.width),\(window.height)") +} else { + print("not-found") +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index aba04fe..f902663 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -347,6 +347,7 @@ const shouldToggleMouseIgnore = !isLinuxPlatform; const INVISIBLE_POSITION_EDIT_TOGGLE_CODE = "KeyP"; const INVISIBLE_POSITION_STEP_PX = 1; const INVISIBLE_POSITION_STEP_FAST_PX = 4; +const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 4; let isOverSubtitle = false; let isDragging = false; @@ -1062,6 +1063,14 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( } } } + + if (isMacOSPlatform && vAlign === 0) { + const currentBottom = parseFloat(subtitleContainer.style.bottom); + if (Number.isFinite(currentBottom)) { + subtitleContainer.style.bottom = `${Math.max(0, currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX)}px`; + } + } + invisibleLayoutBaseLeftPx = parseFloat(subtitleContainer.style.left) || 0; invisibleLayoutBaseBottomPx = Number.isFinite(parseFloat(subtitleContainer.style.bottom)) ? parseFloat(subtitleContainer.style.bottom) diff --git a/src/window-trackers/macos-tracker.ts b/src/window-trackers/macos-tracker.ts index dfbf6aa..6533189 100644 --- a/src/window-trackers/macos-tracker.ts +++ b/src/window-trackers/macos-tracker.ts @@ -17,11 +17,132 @@ */ import { execFile } from "child_process"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; import { BaseWindowTracker } from "./base-tracker"; +import { createLogger } from "../logger"; + +const log = createLogger("tracker").child("macos"); export class MacOSWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; private pollInFlight = false; + private helperPath: string | null = null; + private helperType: "binary" | "swift" | null = null; + private lastExecErrorFingerprint: string | null = null; + private lastExecErrorLoggedAtMs = 0; + + constructor() { + super(); + this.detectHelper(); + } + + private materializeAsarHelper( + sourcePath: string, + helperType: "binary" | "swift", + ): string | null { + if (!sourcePath.includes(".asar")) { + return sourcePath; + } + + const fileName = + helperType === "binary" + ? "get-mpv-window-macos" + : "get-mpv-window-macos.swift"; + const targetDir = path.join(os.tmpdir(), "subminer", "helpers"); + const targetPath = path.join(targetDir, fileName); + + try { + fs.mkdirSync(targetDir, { recursive: true }); + fs.copyFileSync(sourcePath, targetPath); + fs.chmodSync(targetPath, 0o755); + log.info(`Materialized macOS helper from asar: ${targetPath}`); + return targetPath; + } catch (error) { + log.warn(`Failed to materialize helper from asar: ${sourcePath}`, error); + return null; + } + } + + private tryUseHelper( + candidatePath: string, + helperType: "binary" | "swift", + ): boolean { + if (!fs.existsSync(candidatePath)) { + return false; + } + + const resolvedPath = this.materializeAsarHelper(candidatePath, helperType); + if (!resolvedPath) { + return false; + } + + this.helperPath = resolvedPath; + this.helperType = helperType; + log.info(`Using macOS helper (${helperType}): ${resolvedPath}`); + return true; + } + + private detectHelper(): void { + // Prefer resources path (outside asar) in packaged apps. + const resourcesPath = process.resourcesPath; + if (resourcesPath) { + const resourcesBinaryPath = path.join( + resourcesPath, + "scripts", + "get-mpv-window-macos", + ); + if (this.tryUseHelper(resourcesBinaryPath, "binary")) { + return; + } + } + + // Dist binary path (development / unpacked installs). + const distBinaryPath = path.join( + __dirname, + "..", + "..", + "scripts", + "get-mpv-window-macos", + ); + if (this.tryUseHelper(distBinaryPath, "binary")) { + return; + } + + // Fall back to Swift script for development. + const swiftPath = path.join( + __dirname, + "..", + "..", + "scripts", + "get-mpv-window-macos.swift", + ); + if (this.tryUseHelper(swiftPath, "swift")) { + return; + } + + log.warn("macOS window tracking helper not found"); + } + + private maybeLogExecError(err: Error, stderr: string): void { + const now = Date.now(); + const fingerprint = `${err.message}|${stderr.trim()}`; + const shouldLog = + this.lastExecErrorFingerprint !== fingerprint || + now - this.lastExecErrorLoggedAtMs >= 5000; + if (!shouldLog) { + return; + } + this.lastExecErrorFingerprint = fingerprint; + this.lastExecErrorLoggedAtMs = now; + log.warn("macOS helper execution failed", { + helperPath: this.helperPath, + helperType: this.helperType, + error: err.message, + stderr: stderr.trim(), + }); + } start(): void { this.pollInterval = setInterval(() => this.pollGeometry(), 250); @@ -36,42 +157,28 @@ export class MacOSWindowTracker extends BaseWindowTracker { } private pollGeometry(): void { - if (this.pollInFlight) { + if (this.pollInFlight || !this.helperPath || !this.helperType) { return; } this.pollInFlight = true; - const script = ` - set processNames to {"mpv", "MPV", "org.mpv.mpv"} - tell application "System Events" - repeat with procName in processNames - set procList to (every process whose name is procName) - repeat with p in procList - try - if (count of windows of p) > 0 then - set targetWindow to window 1 of p - set windowPos to position of targetWindow - set windowSize to size of targetWindow - return (item 1 of windowPos) & "," & (item 2 of windowPos) & "," & (item 1 of windowSize) & "," & (item 2 of windowSize) - end if - end try - end repeat - end repeat - end tell - return "not-found" - `; + // Use Core Graphics API via Swift helper for reliable window detection + // This works with both bundled and unbundled mpv installations + const command = this.helperType === "binary" ? this.helperPath : "swift"; + const args = this.helperType === "binary" ? [] : [this.helperPath]; execFile( - "osascript", - ["-e", script], + command, + args, { encoding: "utf-8", timeout: 1000, maxBuffer: 1024 * 1024, }, - (err, stdout) => { + (err, stdout, stderr) => { if (err) { + this.maybeLogExecError(err, stderr || ""); this.updateGeometry(null); this.pollInFlight = false; return; From 8a82a1b5f9526482c41c04e51199f5af0537b095 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 11 Feb 2026 18:27:29 -0800 Subject: [PATCH 22/74] Fix renderer overlay loading and modularize renderer --- README.md | 2 +- ...hen-texthooker-only-instance-is-running.md | 51 + ...uncher-shows-visible-overlay-on-startup.md | 43 + ...e-loading-regression-after-task-6-split.md | 34 + ...xperiment-changes-and-keep-renderer-fix.md | 34 + ...cation-between-renderer.ts-and-types.ts.md | 76 +- ... Split-renderer.ts-into-focused-modules.md | 79 +- docs/README.md | 2 +- docs/architecture.md | 28 +- package.json | 2 +- src/core/services/index.ts | 6 +- .../mpv-render-metrics-service.test.ts | 22 +- .../services/mpv-render-metrics-service.ts | 27 + src/main.ts | 19 +- src/renderer/context.ts | 14 + src/renderer/handlers/keyboard.ts | 238 ++ src/renderer/handlers/mouse.ts | 271 ++ src/renderer/index.html | 2 +- src/renderer/modals/jimaku.ts | 378 +++ src/renderer/modals/kiku.ts | 307 ++ src/renderer/modals/runtime-options.ts | 262 ++ src/renderer/modals/subsync.ts | 142 + src/renderer/positioning.ts | 498 +++ src/renderer/renderer.ts | 2830 +---------------- src/renderer/state.ts | 132 + src/renderer/subtitle-render.ts | 206 ++ src/renderer/utils/dom.ts | 131 + src/renderer/utils/platform.ts | 43 + tsconfig.renderer.json | 12 + 29 files changed, 3150 insertions(+), 2741 deletions(-) create mode 100644 backlog/tasks/task-13 - Fix-second-instance-start-when-texthooker-only-instance-is-running.md create mode 100644 backlog/tasks/task-14 - Ensure-subminer-launcher-shows-visible-overlay-on-startup.md create mode 100644 backlog/tasks/task-15 - Fix-renderer-module-loading-regression-after-task-6-split.md create mode 100644 backlog/tasks/task-16 - Revert-overlay-startup-experiment-changes-and-keep-renderer-fix.md create mode 100644 src/renderer/context.ts create mode 100644 src/renderer/handlers/keyboard.ts create mode 100644 src/renderer/handlers/mouse.ts create mode 100644 src/renderer/modals/jimaku.ts create mode 100644 src/renderer/modals/kiku.ts create mode 100644 src/renderer/modals/runtime-options.ts create mode 100644 src/renderer/modals/subsync.ts create mode 100644 src/renderer/positioning.ts create mode 100644 src/renderer/state.ts create mode 100644 src/renderer/subtitle-render.ts create mode 100644 src/renderer/utils/dom.ts create mode 100644 src/renderer/utils/platform.ts create mode 100644 tsconfig.renderer.json diff --git a/README.md b/README.md index eafd931..189485d 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Detailed guides live in [`docs/`](docs/README.md): - [MPV Plugin](docs/mpv-plugin.md) — Chord keybindings, subminer.conf options, script messages - [Troubleshooting](docs/troubleshooting.md) — Common issues and solutions - [Development](docs/development.md) — Building, testing, contributing -- [Architecture](docs/architecture.md) — Service-oriented design, composition model +- [Architecture](docs/architecture.md) — Service-oriented design, composition model, and modular renderer layout (`src/renderer/{modals,handlers,utils,...}`) ### Third-Party Components diff --git a/backlog/tasks/task-13 - Fix-second-instance-start-when-texthooker-only-instance-is-running.md b/backlog/tasks/task-13 - Fix-second-instance-start-when-texthooker-only-instance-is-running.md new file mode 100644 index 0000000..2117a8e --- /dev/null +++ b/backlog/tasks/task-13 - Fix-second-instance-start-when-texthooker-only-instance-is-running.md @@ -0,0 +1,51 @@ +--- +id: TASK-13 +title: Fix second-instance --start when texthooker-only instance is running +status: Done +assignee: [] +created_date: '2026-02-11 23:47' +updated_date: '2026-02-11 23:47' +labels: + - bugfix + - cli + - overlay +dependencies: [] +priority: high +--- + +## Description + + +When SubMiner is already running in texthooker-only mode, a subsequent `--start` command from a second instance is currently ignored. This can leave users without an initialized overlay runtime even though startup commands were issued. Adjust CLI command handling so `--start` on second-instance initializes overlay runtime when it is not yet initialized, while preserving current ignore behavior when overlay runtime is already active. + + +## Acceptance Criteria + +- [x] #1 Second-instance `--start` initializes overlay runtime when current instance has deferred/not-initialized overlay runtime. +- [x] #2 Second-instance `--start` remains ignored (existing behavior) when overlay runtime is already initialized. +- [x] #3 CLI command service tests cover both behaviors and pass. + + +## Implementation Notes + + +Patched CLI second-instance `--start` handling in `src/core/services/cli-command-service.ts` to initialize overlay runtime when deferred. + +Added regression test for deferred-runtime start path and updated initialized-runtime second-instance tests in `src/core/services/cli-command-service.test.ts`. + + +## Final Summary + + +Fixed overlay startup regression path where a second-instance `--start` could be ignored even when the primary instance was running in texthooker-only/deferred overlay mode. + +Changes: +- Updated `handleCliCommandService` logic so `ignoreStart` applies only when source is second-instance, `--start` is present, and overlay runtime is already initialized. +- Added explicit overlay-runtime initialization path for second-instance `--start` when runtime is not initialized. +- Kept existing behavior for already-initialized runtime (still logs and ignores redundant `--start`). +- Added and updated tests in `cli-command-service.test.ts` to validate both deferred and initialized second-instance startup behaviors. + +Validation: +- `pnpm run build` succeeded. +- `node dist/core/services/cli-command-service.test.js` passed (11/11). + diff --git a/backlog/tasks/task-14 - Ensure-subminer-launcher-shows-visible-overlay-on-startup.md b/backlog/tasks/task-14 - Ensure-subminer-launcher-shows-visible-overlay-on-startup.md new file mode 100644 index 0000000..f332b8b --- /dev/null +++ b/backlog/tasks/task-14 - Ensure-subminer-launcher-shows-visible-overlay-on-startup.md @@ -0,0 +1,43 @@ +--- +id: TASK-14 +title: Ensure subminer launcher shows visible overlay on startup +status: Done +assignee: [] +created_date: '2026-02-12 00:22' +updated_date: '2026-02-12 00:23' +labels: + - bugfix + - launcher + - overlay +dependencies: [] +priority: high +--- + +## Description + + +The `subminer` launcher starts SubMiner with `--start` but can leave the visible overlay hidden when runtime config defers auto-show (`auto_start_overlay=false`). Update launcher command args to explicitly request visible overlay at startup so script-mode behavior matches user expectations. + + +## Acceptance Criteria + +- [x] #1 Running `subminer