diff --git a/README.md b/README.md index e01cff2..b961fd1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ For macOS builds and platform details, see the [installation docs](docs/installa ``` 3. Launch SubMiner: ```bash - subminer video.mkv + subminer --start video.mkv + SubMiner.AppImage --background # tray/background mode (desktop launcher default) ``` Config tip: while SubMiner is running, safe config edits (subtitle style, keybindings, shortcuts, secondary subtitle default mode, and `ankiConnect.ai`) hot-reload automatically. @@ -100,7 +101,7 @@ Use `subminer -h` for command-specific help pages (for example `sub - Use `--log-level` to control logger verbosity (for example `--log-level debug`). - Use `--dev` and `--debug` only for app/dev-mode behavior; they are not tied to logging level. -- Default logging remains `info` unless you pass `--log-level`. +- Default logging is `info`, except `--background` mode defaults to `warn` unless `--log-level` is set. ### Overlay Queue Controls diff --git a/backlog/completed/task-66 - Add-AnkiConnect-tagging-for-mined-and-updated-cards.md b/backlog/completed/task-66 - Add-AnkiConnect-tagging-for-mined-and-updated-cards.md new file mode 100644 index 0000000..75b35e4 --- /dev/null +++ b/backlog/completed/task-66 - Add-AnkiConnect-tagging-for-mined-and-updated-cards.md @@ -0,0 +1,45 @@ +--- +id: TASK-66 +title: Add AnkiConnect tagging for mined and updated cards +status: Done +assignee: [] +created_date: '2026-02-18 09:23' +updated_date: '2026-02-18 09:24' +labels: + - anki + - config + - enhancement +dependencies: [] +references: + - src/anki-connect.ts + - src/anki-integration.ts + - src/anki-integration/card-creation.ts + - src/config/definitions.ts + - src/config/service.ts + - src/config/config.test.ts + - docs/configuration.md + - config.example.jsonc + - docs/public/config.example.jsonc +priority: medium +--- + +## Description + + +Support configuring tags applied to cards created or updated through SubMiner's AnkiConnect workflow. Default behavior should add the `SubMiner` tag to all mined/updated cards, while allowing users to override or disable automatic tagging via config. + + +## Acceptance Criteria + +- [x] #1 AnkiConnect note creation supports passing tags. +- [x] #2 Existing-card update flows add configured tags via AnkiConnect after updates. +- [x] #3 Default config includes `ankiConnect.tags: ["SubMiner"]`. +- [x] #4 Config parsing validates `ankiConnect.tags` and falls back safely on invalid values. +- [x] #5 User-facing docs/config examples mention the new tags option and default behavior. + + +## Final Summary + + +Implemented configurable AnkiConnect tagging across SubMiner card creation and update workflows. Added `ankiConnect.tags` config (default `['SubMiner']`), validation/fallback in config parsing, AnkiConnect client support for `addNote` tags + `addTags`, and integration hooks so created/updated/merged cards receive configured tags. Updated docs and regenerated config examples; config test suite passes. + diff --git a/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md b/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md new file mode 100644 index 0000000..6e75122 --- /dev/null +++ b/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md @@ -0,0 +1,67 @@ +--- +id: TASK-65 +title: Run Electron app as background tray service with IPC startup +status: Done +assignee: [] +created_date: '2026-02-18 08:48' +updated_date: '2026-02-18 10:17' +labels: + - electron + - tray + - ipc + - desktop-entry +dependencies: [] +priority: high +--- + +## Description + + +Allow launching the app from desktop/application entry so it starts in background with minimal logging, waits for IPC connection, and exposes tray icon for settings/configuration. + + +## Acceptance Criteria + +- [x] #1 Launching via app/desktop file starts app without foreground window by default +- [x] #2 Process runs in background with minimal startup logging +- [x] #3 Main process initializes IPC server/client wait loop for incoming connection +- [x] #4 Tray icon is visible and provides menu actions for settings/configuration and quit +- [x] #5 Behavior documented for desktop launch usage + + +## Implementation Notes + + +Implemented `--background` CLI mode to keep app running without overlay windows, default to quieter logging, and preserve IPC client startup. + +Added persistent tray runtime (icon + menu actions for overlay, Yomitan settings, runtime options, Jellyfin setup, AniList setup, quit). + +Disabled window-all-closed auto-quit while in background mode; desktop Linux launcher now passes `--background` by default via electron-builder desktop Exec. + +Updated usage/installation docs and CLI help; validated with `bun run build && bun run test:fast`. + +Follow-up packaging fix: replaced invalid `build.linux.desktop.Exec` override with supported electron-builder `build.linux.executableArgs: ["--background"]` after AppImage schema validation failure on electron-builder 26.7.0. + +Re-verified Linux packaging with `bun run build:appimage`; background launcher argument is now applied without config validation errors. + +Correction: any earlier mention of `desktop Exec` is obsolete; final shipped config uses `build.linux.executableArgs` only. + +Background launch now detaches from terminal via new `src/main-entry.ts` bootstrap: `--background` parent process spawns detached child and exits, so Ctrl+C no longer stops the running tray process. + +Background detached child now suppresses Node runtime warnings (`NODE_NO_WARNINGS=1`) and strips `VK_INSTANCE_LAYERS` when it contains `lsfg` to reduce non-actionable startup noise in background mode. + +Updated package entrypoint to `dist/main-entry.js` and docs usage note for detached background behavior. + + +## Final Summary + + +Added an always-on background startup mode for desktop launches by introducing `--background`, wiring startup/lifecycle state to keep the process alive without windows, and defaulting this mode to quieter logging unless explicitly overridden. Added a tray icon/menu for settings and runtime configuration entry points and updated Linux desktop packaging so launcher executions use background mode by default, with docs and tests updated accordingly. + +Added detached background bootstrap behavior for `--background` launches so terminal invocations return immediately while the tray process continues independently, and reduced background startup noise by suppressing Node warnings and removing problematic lsfg Vulkan layer env in detached child startups. + + +## Definition of Done + +- [x] #1 Implementation verified locally with launch command or desktop entry simulation + diff --git a/backlog/tasks/task-67 - Make-wrapper-stop-auto-sending-start-by-default.md b/backlog/tasks/task-67 - Make-wrapper-stop-auto-sending-start-by-default.md new file mode 100644 index 0000000..86256ae --- /dev/null +++ b/backlog/tasks/task-67 - Make-wrapper-stop-auto-sending-start-by-default.md @@ -0,0 +1,53 @@ +--- +id: TASK-67 +title: Make wrapper stop auto-sending --start by default +status: Done +assignee: [] +created_date: '2026-02-18 09:47' +updated_date: '2026-02-18 10:02' +labels: + - launcher + - wrapper + - cli + - background-mode +dependencies: [] +priority: high +--- + +## Description + + +Update the subminer wrapper flow so it no longer appends --start automatically when launching the app, requiring explicit start semantics and allowing background AppImage workflow to be primary. + + +## Acceptance Criteria + +- [x] #1 Wrapper launch path does not append --start unless explicitly requested +- [x] #2 Existing explicit startup commands still work and are documented +- [x] #3 Tests updated for new wrapper behavior + + +## Implementation Notes + + +Launcher wrapper no longer auto-starts overlay from mpv plugin `auto_start` setting; only explicit `--start`/`--start-overlay` trigger wrapper-managed startup. + +Simplified plugin runtime config parsing in launcher to consume `socket_path` only for wrapper behavior. + +Updated docs examples and descriptions to make explicit startup flow clear (`subminer --start video.mkv`), and rebuilt bundled `subminer` script. + +Validated with `bun run build && make build-launcher && bun run test:fast`. + +Follow-up: updated mpv plugin command builder (`plugin/subminer.lua`) to stop forcing `--start` for toggle/show/hide actions; only explicit `start` action now sends start context. This avoids second-instance command failures when app is already running in background mode. + + +## Final Summary + + +Updated the `subminer` wrapper to stop implicitly issuing app `--start` based on plugin auto-start settings, so background AppImage usage is the default and overlay startup happens only on explicit wrapper flags (`--start`/`--start-overlay`) or manual plugin commands. Also updated launcher docs/examples to reflect explicit startup semantics and regenerated the bundled `subminer` script. + + +## Definition of Done + +- [x] #1 Validated with launcher-related tests or command simulation + diff --git a/backlog/tasks/task-68 - Allow-trailing-commas-in-JSONC-config-parsing.md b/backlog/tasks/task-68 - Allow-trailing-commas-in-JSONC-config-parsing.md new file mode 100644 index 0000000..ff8b9e7 --- /dev/null +++ b/backlog/tasks/task-68 - Allow-trailing-commas-in-JSONC-config-parsing.md @@ -0,0 +1,49 @@ +--- +id: TASK-68 +title: Allow trailing commas in JSONC config parsing +status: Done +assignee: [] +created_date: '2026-02-18 10:13' +updated_date: '2026-02-18 10:13' +labels: + - config + - jsonc +dependencies: [] +priority: medium +--- + +## Description + + +Permit trailing commas in `config.jsonc` parsing so normal JSONC edits do not fail strict reload/watcher startup. + + +## Acceptance Criteria + +- [x] #1 Config parser accepts trailing commas in JSONC objects/arrays +- [x] #2 Invalid malformed JSONC still fails strict reload +- [x] #3 Coverage added to prevent regression + + +## Implementation Notes + + +Enabled `allowTrailingComma: true` in `src/config/service.ts` JSONC parse options while preserving strict parse error handling. + +Added regression test `accepts trailing commas in jsonc` in `src/config/config.test.ts`. + +Updated docs note in `docs/configuration.md` that JSONC config supports comments and trailing commas. + +Validated with `bun run build && bun run test:config:dist`. + + +## Final Summary + + +SubMiner now accepts trailing commas in `config.jsonc` by enabling JSONC parser trailing-comma support in the strict config load path. Strict reload behavior remains intact for malformed JSON/JSONC, and a regression test now covers trailing-comma acceptance. Documentation was updated accordingly. + + +## Definition of Done + +- [x] #1 Config test suite passes after parser change + diff --git a/config.example.jsonc b/config.example.jsonc index 2fa90c3..4c2ddaa 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -56,6 +56,9 @@ "enabled": false, "url": "http://127.0.0.1:8765", "pollingRate": 3000, + "tags": [ + "SubMiner" + ], "fields": { "audio": "ExpressionAudio", "image": "Picture", diff --git a/docs/configuration.md b/docs/configuration.md index 193dd06..64d7f36 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,7 @@ SubMiner.AppImage --generate-config --backup-overwrite ``` - `--generate-config` writes a default JSONC config template. +- JSONC config supports comments and trailing commas. - If the target file exists, SubMiner prompts to create a timestamped backup and overwrite. - In non-interactive shells, use `--backup-overwrite` to explicitly back up and overwrite. - `bun run generate:config-example` regenerates both repository `config.example.jsonc` and docs-served `/config.example.jsonc` from the same centralized defaults. @@ -94,6 +95,7 @@ Enable automatic Anki card creation and updates with media generation: "enabled": true, "url": "http://127.0.0.1:8765", "pollingRate": 3000, + "tags": ["SubMiner"], "deck": "Learning::Japanese", "fields": { "audio": "ExpressionAudio", @@ -159,6 +161,7 @@ This example is intentionally compact. The option table below documents availabl | `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `false`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `pollingRate` | number (ms) | How often to check for new cards (default: `3000`) | +| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `deck` | string | Anki deck to monitor for new cards | | `ankiConnect.nPlusOne.decks` | array of strings | Decks used for N+1 known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | diff --git a/docs/development.md b/docs/development.md index 63f82b6..f472093 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,6 +36,7 @@ make build-macos-unsigned # macOS DMG + ZIP (unsigned) ```bash bun run dev # builds + launches with --start --dev electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging +electron . --background # tray/background mode, minimal default logging ``` ## Testing @@ -97,6 +98,7 @@ Run `make help` for a full list of targets. Key ones: - 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.ts`. - Main process composition is now split across `src/main/` modules (`startup.ts`, `app-lifecycle.ts`, `startup-lifecycle.ts`, `state.ts`, `ipc-runtime.ts`, `cli-runtime.ts`, `overlay-runtime.ts`, `subsync-runtime.ts`). +- Linux packaged desktop launches pass `--background` using electron-builder `build.linux.executableArgs` in `package.json`. - MPV service has been split into transport, protocol, state, and properties layers in `src/core/services/`. - Prefer direct inline deps objects in `src/main/` modules for simple pass-through wiring. - Add a helper/adapter service only when it performs meaningful adaptation, validation, or reuse (not identity mapping). diff --git a/docs/installation.md b/docs/installation.md index c0f4623..41d0581 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -183,13 +183,14 @@ After installing, confirm SubMiner is working: ```bash # Start the overlay (connects to mpv IPC) -subminer video.mkv +subminer --start video.mkv # Useful launch modes for troubleshooting subminer --log-level debug video.mkv SubMiner.AppImage --start --log-level debug # Or with direct AppImage control +SubMiner.AppImage --background # Background tray service mode SubMiner.AppImage --start SubMiner.AppImage --start --dev SubMiner.AppImage --help # Show all CLI options diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index 2fa90c3..4c2ddaa 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -56,6 +56,9 @@ "enabled": false, "url": "http://127.0.0.1:8765", "pollingRate": 3000, + "tags": [ + "SubMiner" + ], "fields": { "audio": "ExpressionAudio", "image": "Picture", diff --git a/docs/usage.md b/docs/usage.md index 9887677..9c43c45 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -4,12 +4,12 @@ There are two ways to use SubMiner — the `subminer` wrapper script or the mpv | Approach | Best For | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, starts the overlay automatically, and cleans up on exit. | +| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. Overlay start is explicit (`--start`, `-S`, or `y-s`). | | **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`. | 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`. +`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer --start video.mkv`. ## Live Config Reload @@ -35,6 +35,7 @@ subminer -R # Use rofi instead of fzf subminer -d ~/Videos # Specific directory subminer -r -d ~/Anime # Recursive search subminer video.mkv # Play specific file +subminer --start video.mkv # Play + explicitly start overlay subminer https://youtu.be/... # Play a YouTube URL subminer ytsearch:"jp news" # Play first YouTube search result subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging @@ -90,7 +91,8 @@ SubMiner.AppImage --help # Show all options - `--log-level` controls logger verbosity. - `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases. - `--background` defaults to quieter logging (`warn`) unless `--log-level` is set. -- Linux desktop launcher starts SubMiner with `--background` by default. +- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`. +- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`). - Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`. ### Launcher Subcommands diff --git a/launcher/config.ts b/launcher/config.ts index e9c7e1a..e6752df 100644 --- a/launcher/config.ts +++ b/launcher/config.ts @@ -24,7 +24,6 @@ import { resolvePathMaybe, isUrlTarget, uniqueNormalizedLangCodes, - parseBoolLike, inferWhisperLanguage, } from './util.js'; @@ -160,7 +159,6 @@ function getPluginConfigCandidates(): string[] { export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { const runtimeConfig: PluginRuntimeConfig = { - autoStartOverlay: false, socketPath: DEFAULT_SOCKET_PATH, }; const candidates = getPluginConfigCandidates(); @@ -173,16 +171,6 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig for (const line of lines) { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith('#')) continue; - const autoStartMatch = trimmed.match(/^auto_start\s*=\s*(.+)$/i); - if (autoStartMatch) { - const value = (autoStartMatch[1] || '').split('#', 1)[0]?.trim() || ''; - const parsed = parseBoolLike(value); - if (parsed !== null) { - runtimeConfig.autoStartOverlay = parsed; - } - continue; - } - const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i); if (socketMatch) { const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || ''; @@ -192,7 +180,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig log( 'debug', logLevel, - `Using mpv plugin settings from ${configPath}: auto_start=${runtimeConfig.autoStartOverlay ? 'yes' : 'no'} socket_path=${runtimeConfig.socketPath}`, + `Using mpv plugin settings from ${configPath}: socket_path=${runtimeConfig.socketPath}`, ); return runtimeConfig; } catch { @@ -204,7 +192,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig log( 'debug', logLevel, - `No mpv subminer.conf found; using launcher defaults (auto_start=no socket_path=${runtimeConfig.socketPath})`, + `No mpv subminer.conf found; using launcher defaults (socket_path=${runtimeConfig.socketPath})`, ); return runtimeConfig; } diff --git a/launcher/main.ts b/launcher/main.ts index 78417c1..b9e7044 100644 --- a/launcher/main.ts +++ b/launcher/main.ts @@ -364,8 +364,7 @@ async function main(): Promise { } const ready = await waitForSocket(mpvSocketPath); - const shouldStartOverlay = - args.startOverlay || args.autoStartOverlay || pluginRuntimeConfig.autoStartOverlay; + const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; if (shouldStartOverlay) { if (ready) { log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay'); diff --git a/launcher/types.ts b/launcher/types.ts index 1b60e16..e952e4e 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -121,7 +121,6 @@ export interface LauncherJellyfinConfig { } export interface PluginRuntimeConfig { - autoStartOverlay: boolean; socketPath: string; } diff --git a/package.json b/package.json index c53dfd7..9aeec7e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", - "main": "dist/main.js", + "main": "dist/main-entry.js", "scripts": { "get-frequency": "bun run scripts/get_frequency.ts --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line", "get-frequency:electron": "bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line", diff --git a/plugin/subminer.lua b/plugin/subminer.lua index 5f0675e..dc5af5a 100644 --- a/plugin/subminer.lua +++ b/plugin/subminer.lua @@ -281,25 +281,9 @@ local function build_command_args(action, overrides) table.insert(args, log_level) end - local needs_start_context = ( - action == "start" - or action == "toggle" - or action == "show" - or action == "hide" - or action == "toggle-visible-overlay" - or action == "show-visible-overlay" - or action == "hide-visible-overlay" - or action == "toggle-invisible-overlay" - or action == "show-invisible-overlay" - or action == "hide-invisible-overlay" - ) + local needs_start_context = action == "start" if needs_start_context then - -- Explicitly request MPV IPC connection for active overlay control. - if action ~= "start" then - table.insert(args, "--start") - end - local backend = resolve_backend(overrides.backend) if backend and backend ~= "" then table.insert(args, "--backend") diff --git a/src/anki-connect.ts b/src/anki-connect.ts index 3939ff0..a67dd47 100644 --- a/src/anki-connect.ts +++ b/src/anki-connect.ts @@ -192,13 +192,35 @@ export class AnkiConnectClient { deckName: string, modelName: string, fields: Record, + tags: string[] = [], ): Promise { + const note: { + deckName: string; + modelName: string; + fields: Record; + tags?: string[]; + } = { deckName, modelName, fields }; + if (tags.length > 0) { + note.tags = tags; + } + const result = await this.invoke('addNote', { - note: { deckName, modelName, fields }, + note, }); return result as number; } + async addTags(noteIds: number[], tags: string[]): Promise { + if (noteIds.length === 0 || tags.length === 0) { + return; + } + + await this.invoke('addTags', { + notes: noteIds, + tags: tags.join(' '), + }); + } + async deleteNotes(noteIds: number[]): Promise { await this.invoke('deleteNotes', { notes: noteIds }); } diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 62e03a3..4cba9d1 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -175,7 +175,9 @@ export class AnkiIntegration { getMpvClient: () => this.mpvClient, getDeck: () => this.config.deck, client: { - addNote: (deck, modelName, fields) => this.client.addNote(deck, modelName, fields), + addNote: (deck, modelName, fields, tags) => + this.client.addNote(deck, modelName, fields, tags), + addTags: (noteIds, tags) => this.client.addTags(noteIds, tags), notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields) as Promise, @@ -295,6 +297,25 @@ export class AnkiIntegration { this.knownWordCache.stopLifecycle(); } + private getConfiguredAnkiTags(): string[] { + if (!Array.isArray(this.config.tags)) { + return []; + } + return [...new Set(this.config.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))]; + } + + private async addConfiguredTagsToNote(noteId: number): Promise { + const tags = this.getConfiguredAnkiTags(); + if (tags.length === 0) { + return; + } + try { + await this.client.addTags([noteId], tags); + } catch (error) { + log.warn('Failed to add tags to card:', (error as Error).message); + } + } + async refreshKnownWordCache(): Promise { return this.knownWordCache.refresh(true); } @@ -512,6 +533,7 @@ export class AnkiIntegration { if (updatePerformed) { await this.client.updateNoteFields(noteId, updatedFields); + await this.addConfiguredTagsToNote(noteId); log.info('Updated card fields for:', expressionText); await this.showNotification(noteId, expressionText); } @@ -1465,6 +1487,7 @@ export class AnkiIntegration { if (Object.keys(mergedFields).length > 0) { await this.client.updateNoteFields(keepNoteId, mergedFields); + await this.addConfiguredTagsToNote(keepNoteId); } if (deleteDuplicate) { @@ -1621,6 +1644,7 @@ export class AnkiIntegration { await this.client.updateNoteFields(noteId, { [resolvedMiscField]: nextValue, }); + await this.addConfiguredTagsToNote(noteId); } applyRuntimeConfigPatch(patch: Partial): void { diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 729b2ec..03cc4bc 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -16,7 +16,13 @@ export interface CardCreationNoteInfo { type CardKind = 'sentence' | 'audio'; interface CardCreationClient { - addNote(deck: string, modelName: string, fields: Record): Promise; + addNote( + deck: string, + modelName: string, + fields: Record, + tags?: string[], + ): Promise; + addTags(noteIds: number[], tags: string[]): Promise; notesInfo(noteIds: number[]): Promise; updateNoteFields(noteId: number, fields: Record): Promise; storeMediaFile(filename: string, data: Buffer): Promise; @@ -101,6 +107,26 @@ interface CardCreationDeps { export class CardCreationService { constructor(private readonly deps: CardCreationDeps) {} + private getConfiguredAnkiTags(): string[] { + const tags = this.deps.getConfig().tags; + if (!Array.isArray(tags)) { + return []; + } + return [...new Set(tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))]; + } + + private async addConfiguredTagsToNote(noteId: number): Promise { + const tags = this.getConfiguredAnkiTags(); + if (tags.length === 0) { + return; + } + try { + await this.deps.client.addTags([noteId], tags); + } catch (error) { + log.warn('Failed to add tags to card:', (error as Error).message); + } + } + async updateLastAddedFromClipboard(clipboardText: string): Promise { try { if (!clipboardText || !clipboardText.trim()) { @@ -272,6 +298,7 @@ export class CardCreationService { if (updatePerformed) { await this.deps.client.updateNoteFields(noteId, updatedFields); + await this.addConfiguredTagsToNote(noteId); const label = expressionText || noteId; log.info('Updated card from clipboard:', label); const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined; @@ -408,6 +435,7 @@ export class CardCreationService { } await this.deps.client.updateNoteFields(noteId, updatedFields); + await this.addConfiguredTagsToNote(noteId); const label = expressionText || noteId; log.info('Marked card as audio card:', label); const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined; @@ -490,7 +518,12 @@ export class CardCreationService { const deck = this.deps.getConfig().deck || 'Default'; let noteId: number; try { - noteId = await this.deps.client.addNote(deck, sentenceCardModel, fields); + noteId = await this.deps.client.addNote( + deck, + sentenceCardModel, + fields, + this.getConfiguredAnkiTags(), + ); log.info('Created sentence card:', noteId); this.deps.trackLastAddedNoteId?.(noteId); } catch (error) { diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 9cd50d8..1d2e76b 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -4,6 +4,7 @@ import { hasExplicitCommand, parseArgs, shouldStartApp } from './args'; test('parseArgs parses booleans and value flags', () => { const args = parseArgs([ + '--background', '--start', '--socket', '/tmp/mpv.sock', @@ -22,6 +23,7 @@ test('parseArgs parses booleans and value flags', () => { '2', ]); + assert.equal(args.background, true); assert.equal(args.start, true); assert.equal(args.socketPath, '/tmp/mpv.sock'); assert.equal(args.backend, 'hyprland'); @@ -93,4 +95,9 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(jellyfinRemoteAnnounce.jellyfinRemoteAnnounce, true); assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true); assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false); + + const background = parseArgs(['--background']); + assert.equal(background.background, true); + assert.equal(hasExplicitCommand(background), true); + assert.equal(shouldStartApp(background), true); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index 85a98c1..5d5a548 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,4 +1,5 @@ export interface CliArgs { + background: boolean; start: boolean; stop: boolean; toggle: boolean; @@ -61,6 +62,7 @@ export type CliCommandSource = 'initial' | 'second-instance'; export function parseArgs(argv: string[]): CliArgs { const args: CliArgs = { + background: false, start: false, stop: false, toggle: false, @@ -115,7 +117,8 @@ export function parseArgs(argv: string[]): CliArgs { const arg = argv[i]; if (!arg.startsWith('--')) continue; - if (arg === '--start') args.start = true; + if (arg === '--background') args.background = true; + else if (arg === '--start') args.start = true; else if (arg === '--stop') args.stop = true; else if (arg === '--toggle') args.toggle = true; else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true; @@ -255,6 +258,7 @@ export function parseArgs(argv: string[]): CliArgs { export function hasExplicitCommand(args: CliArgs): boolean { return ( + args.background || args.start || args.stop || args.toggle || @@ -299,6 +303,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { export function shouldStartApp(args: CliArgs): boolean { if (args.stop && !args.start) return false; if ( + args.background || args.start || args.toggle || args.toggleVisibleOverlay || diff --git a/src/cli/help.ts b/src/cli/help.ts index 7816bb4..ecdb0e1 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -10,6 +10,7 @@ ${B}SubMiner${R} — Japanese sentence mining with mpv + Yomitan ${B}Usage:${R} subminer ${D}[command] [options]${R} ${B}Session${R} + --background Start in tray/background mode --start Connect to mpv and launch overlay --stop Stop the running instance --texthooker Start texthooker server only ${D}(no overlay)${R} diff --git a/src/config/config.test.ts b/src/config/config.test.ts index f0cbf4c..87027c9 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -17,6 +17,7 @@ test('loads defaults when config is missing', () => { const config = service.getConfig(); assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port); assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true); + assert.deepEqual(config.ankiConnect.tags, ['SubMiner']); assert.equal(config.anilist.enabled, false); assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true); @@ -258,6 +259,28 @@ test('parses jsonc and warns/falls back on invalid value', () => { assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port')); }); +test('accepts trailing commas in jsonc', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "websocket": { + "enabled": "auto", + "port": 7788, + }, + "youtubeSubgen": { + "primarySubLanguages": ["ja", "en",], + }, + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + assert.equal(config.websocket.port, 7788); + assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'en']); +}); + test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => { const dir = makeTempDir(); const configPath = path.join(dir, 'config.jsonc'); @@ -631,6 +654,44 @@ test('accepts valid ankiConnect n+1 deck list', () => { assert.deepEqual(config.ankiConnect.nPlusOne.decks, ['Deck One', 'Deck Two']); }); +test('accepts valid ankiConnect tags list', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "tags": ["SubMiner", "Mining"] + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.deepEqual(config.ankiConnect.tags, ['SubMiner', 'Mining']); +}); + +test('falls back to default when ankiConnect tags list is invalid', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "tags": ["SubMiner", 123] + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.deepEqual(config.ankiConnect.tags, ['SubMiner']); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags')); +}); + test('falls back to default when ankiConnect n+1 deck list is invalid', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 10cc56f..938c255 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -79,6 +79,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { enabled: false, url: 'http://127.0.0.1:8765', pollingRate: 3000, + tags: ['SubMiner'], fields: { audio: 'ExpressionAudio', image: 'Picture', @@ -397,6 +398,13 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [ defaultValue: DEFAULT_CONFIG.ankiConnect.pollingRate, description: 'Polling interval in milliseconds.', }, + { + path: 'ankiConnect.tags', + kind: 'array', + defaultValue: DEFAULT_CONFIG.ankiConnect.tags, + description: + 'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.', + }, { path: 'ankiConnect.behavior.autoUpdateNewCards', kind: 'boolean', diff --git a/src/config/service.ts b/src/config/service.ts index 35e611f..145d554 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -174,7 +174,10 @@ export class ConfigService { const parsed = configPath.endsWith('.jsonc') ? (() => { const errors: ParseError[] = []; - const result = parseJsonc(data, errors); + const result = parseJsonc(data, errors, { + allowTrailingComma: true, + disallowComments: false, + }); if (errors.length > 0) { throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`); } @@ -889,6 +892,32 @@ export class ConfigService { }, }; + if (Array.isArray(ac.tags)) { + const normalizedTags = ac.tags + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (normalizedTags.length === ac.tags.length) { + resolved.ankiConnect.tags = [...new Set(normalizedTags)]; + } else { + resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + warn( + 'ankiConnect.tags', + ac.tags, + resolved.ankiConnect.tags, + 'Expected an array of non-empty strings.', + ); + } + } else if (ac.tags !== undefined) { + resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + warn( + 'ankiConnect.tags', + ac.tags, + resolved.ankiConnect.tags, + 'Expected an array of strings.', + ); + } + const legacy = ac as Record; const mapLegacy = (key: string, apply: (value: unknown) => void): void => { if (legacy[key] !== undefined) apply(legacy[key]); diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index bb40ec8..da4536a 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -21,6 +21,7 @@ export interface AppLifecycleServiceDeps { onWillQuitCleanup: () => void; shouldRestoreWindowsOnActivate: () => boolean; restoreWindowsOnActivate: () => void; + shouldQuitOnWindowAllClosed: () => boolean; } interface AppLike { @@ -42,6 +43,7 @@ export interface AppLifecycleDepsRuntimeOptions { onWillQuitCleanup: () => void; shouldRestoreWindowsOnActivate: () => boolean; restoreWindowsOnActivate: () => void; + shouldQuitOnWindowAllClosed: () => boolean; } export function createAppLifecycleDepsRuntime( @@ -80,6 +82,7 @@ export function createAppLifecycleDepsRuntime( onWillQuitCleanup: options.onWillQuitCleanup, shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate, restoreWindowsOnActivate: options.restoreWindowsOnActivate, + shouldQuitOnWindowAllClosed: options.shouldQuitOnWindowAllClosed, }; } @@ -119,7 +122,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic }); deps.onWindowAllClosed(() => { - if (!deps.isDarwinPlatform()) { + if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) { deps.quitApp(); } }); diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index c816f68..35c17b0 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -5,6 +5,7 @@ import { CliCommandServiceDeps, handleCliCommand } from './cli-command'; function makeArgs(overrides: Partial = {}): CliArgs { return { + background: false, start: false, stop: false, toggle: false, diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index c29647d..db7d24f 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -5,6 +5,7 @@ import { CliArgs } from '../../cli/args'; function makeArgs(overrides: Partial = {}): CliArgs { return { + background: false, start: false, stop: false, toggle: false, @@ -80,6 +81,7 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle', assert.equal(result.backendOverride, 'x11'); assert.equal(result.autoStartOverlay, true); assert.equal(result.texthookerOnlyMode, true); + assert.equal(result.backgroundMode, false); assert.deepEqual(calls, ['setLog:debug:cli', 'forceX11', 'enforceWayland', 'startLifecycle']); }); @@ -130,6 +132,7 @@ test('runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flag assert.equal(result.backendOverride, null); assert.equal(result.autoStartOverlay, false); assert.equal(result.texthookerOnlyMode, false); + assert.equal(result.backgroundMode, false); assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']); }); @@ -173,5 +176,26 @@ test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handl assert.equal(result.mpvSocketPath, '/tmp/default.sock'); assert.equal(result.texthookerPort, 5174); assert.equal(result.backendOverride, null); + assert.equal(result.backgroundMode, false); assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']); }); + +test('runStartupBootstrapRuntime enables quiet background mode by default', () => { + const calls: string[] = []; + const args = makeArgs({ background: true }); + + const result = runStartupBootstrapRuntime({ + argv: ['node', 'main.ts', '--background'], + parseArgs: () => args, + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), + forceX11Backend: () => calls.push('forceX11'), + enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), + getDefaultSocketPath: () => '/tmp/default.sock', + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => false, + startAppLifecycle: () => calls.push('startLifecycle'), + }); + + assert.equal(result.backgroundMode, true); + assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']); +}); diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index bf4b718..ac0515e 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -9,6 +9,7 @@ export interface StartupBootstrapRuntimeState { backendOverride: string | null; autoStartOverlay: boolean; texthookerOnlyMode: boolean; + backgroundMode: boolean; } interface RuntimeAutoUpdateOptionManagerLike { @@ -47,6 +48,8 @@ export function runStartupBootstrapRuntime( if (initialArgs.logLevel) { deps.setLogLevel(initialArgs.logLevel, 'cli'); + } else if (initialArgs.background) { + deps.setLogLevel('warn', 'cli'); } deps.forceX11Backend(initialArgs); @@ -59,6 +62,7 @@ export function runStartupBootstrapRuntime( backendOverride: initialArgs.backend ?? null, autoStartOverlay: initialArgs.autoStartOverlay, texthookerOnlyMode: initialArgs.texthooker, + backgroundMode: initialArgs.background, }; if (!deps.runGenerateConfigFlow(initialArgs)) { diff --git a/src/main-entry.ts b/src/main-entry.ts new file mode 100644 index 0000000..b0d5a49 --- /dev/null +++ b/src/main-entry.ts @@ -0,0 +1,35 @@ +import { spawn } from 'node:child_process'; + +const BACKGROUND_ARG = '--background'; +const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; + +function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean { + if (env.ELECTRON_RUN_AS_NODE === '1') return false; + if (!argv.includes(BACKGROUND_ARG)) return false; + if (env[BACKGROUND_CHILD_ENV] === '1') return false; + return true; +} + +function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { ...baseEnv }; + env[BACKGROUND_CHILD_ENV] = '1'; + if (!env.NODE_NO_WARNINGS) { + env.NODE_NO_WARNINGS = '1'; + } + if (typeof env.VK_INSTANCE_LAYERS === 'string' && /lsfg/i.test(env.VK_INSTANCE_LAYERS)) { + delete env.VK_INSTANCE_LAYERS; + } + return env; +} + +if (shouldDetachBackgroundLaunch(process.argv, process.env)) { + const child = spawn(process.execPath, process.argv.slice(1), { + detached: true, + stdio: 'ignore', + env: sanitizeBackgroundEnv(process.env), + }); + child.unref(); + process.exit(0); +} + +require('./main.js'); diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 068bc41..32769f4 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -15,6 +15,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput { onWillQuitCleanup: () => void; shouldRestoreWindowsOnActivate: () => boolean; restoreWindowsOnActivate: () => void; + shouldQuitOnWindowAllClosed: () => boolean; } export interface AppReadyRuntimeDepsFactoryInput { @@ -59,6 +60,7 @@ export function createAppLifecycleRuntimeDeps( onWillQuitCleanup: params.onWillQuitCleanup, shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate, restoreWindowsOnActivate: params.restoreWindowsOnActivate, + shouldQuitOnWindowAllClosed: params.shouldQuitOnWindowAllClosed, }; } diff --git a/src/main/startup-lifecycle.ts b/src/main/startup-lifecycle.ts index e2fa461..4444726 100644 --- a/src/main/startup-lifecycle.ts +++ b/src/main/startup-lifecycle.ts @@ -16,6 +16,7 @@ export interface AppLifecycleRuntimeRunnerParams { onWillQuitCleanup: () => void; shouldRestoreWindowsOnActivate: () => boolean; restoreWindowsOnActivate: () => void; + shouldQuitOnWindowAllClosed: () => boolean; } export function createAppLifecycleRuntimeRunner( @@ -37,6 +38,7 @@ export function createAppLifecycleRuntimeRunner( onWillQuitCleanup: params.onWillQuitCleanup, shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate, restoreWindowsOnActivate: params.restoreWindowsOnActivate, + shouldQuitOnWindowAllClosed: params.shouldQuitOnWindowAllClosed, }), ), ); diff --git a/src/main/state.ts b/src/main/state.ts index bbad0f5..e6db108 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -78,6 +78,7 @@ export interface AppState { backendOverride: string | null; autoStartOverlay: boolean; texthookerOnlyMode: boolean; + backgroundMode: boolean; jlptLevelLookup: (term: string) => JlptLevel | null; frequencyRankLookup: FrequencyDictionaryLookup; anilistSetupPageOpened: boolean; @@ -90,6 +91,7 @@ export interface AppStateInitialValues { backendOverride?: string | null; autoStartOverlay?: boolean; texthookerOnlyMode?: boolean; + backgroundMode?: boolean; } export interface StartupState { @@ -99,6 +101,7 @@ export interface StartupState { backendOverride: AppState['backendOverride']; autoStartOverlay: AppState['autoStartOverlay']; texthookerOnlyMode: AppState['texthookerOnlyMode']; + backgroundMode: AppState['backgroundMode']; } export function createAppState(values: AppStateInitialValues): AppState { @@ -152,6 +155,7 @@ export function createAppState(values: AppStateInitialValues): AppState { backendOverride: values.backendOverride ?? null, autoStartOverlay: values.autoStartOverlay ?? false, texthookerOnlyMode: values.texthookerOnlyMode ?? false, + backgroundMode: values.backgroundMode ?? false, jlptLevelLookup: () => null, frequencyRankLookup: () => null, anilistSetupPageOpened: false, @@ -172,4 +176,5 @@ export function applyStartupState(appState: AppState, startupState: StartupState appState.backendOverride = startupState.backendOverride; appState.autoStartOverlay = startupState.autoStartOverlay; appState.texthookerOnlyMode = startupState.texthookerOnlyMode; + appState.backgroundMode = startupState.backgroundMode; } diff --git a/src/types.ts b/src/types.ts index 7e37c98..6b625fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -194,6 +194,7 @@ export interface AnkiConnectConfig { enabled?: boolean; url?: string; pollingRate?: number; + tags?: string[]; fields?: { audio?: string; image?: string; @@ -423,6 +424,7 @@ export interface ResolvedConfig { enabled: boolean; url: string; pollingRate: number; + tags: string[]; fields: { audio: string; image: string;