diff --git a/README.md b/README.md index 7970f43..8472235 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@
-[![SubMiner demo (GIF preview)](./assets/minecard.gif)](./assets/minecard.mp4) +[![SubMiner demo (Animated preview)](./assets/minecard.webp)](./assets/minecard.mp4)
@@ -69,7 +69,7 @@ mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/c ### 3. Set up Yomitan Dictionaries ```bash -subminer app --start --yomitan +subminer app --yomitan ``` ### 4. Mine diff --git a/docs/anki-integration.md b/docs/anki-integration.md index bcc7302..a0522c3 100644 --- a/docs/anki-integration.md +++ b/docs/anki-integration.md @@ -1,6 +1,7 @@ # 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. +This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior. ## Prerequisites diff --git a/docs/configuration.md b/docs/configuration.md index d15ba1c..863b5f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,6 +97,7 @@ The configuration file includes several main sections: **Anki Integration** - [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media +- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types - [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting - [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging @@ -662,13 +663,28 @@ This example is intentionally compact. The option table below documents availabl | `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | | `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | -**Kiku / Lapis Note Type Support:** +### Kiku/Lapis Integration -SubMiner supports the [Lapis](https://github.com/donkuri/lapis) and [Kiku](https://kiku.youyoumu.my.id/) note types. Both `isLapis.enabled` and `isKiku.enabled` can be true; Kiku takes precedence for grouping behavior, while sentence-card model/field settings come from `isLapis`. +SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) workflows, with note-type-specific behavior built into Anki settings. -When enabled, sentence cards automatically set `IsSentenceCard` to `"x"` and populate the `Expression` field. Audio cards set `IsAudioCard` to `"x"`. +```jsonc +"ankiConnect": { + "isLapis": { + "enabled": true, + "sentenceCardModel": "Japanese sentences" + }, + "isKiku": { + "enabled": true, + "fieldGrouping": "manual", + "deleteDuplicateInAuto": true + } +} +``` -Kiku extends Lapis with **field grouping** — when a duplicate card is detected (same Word/Expression), SubMiner merges the two cards' content into one using Kiku's `data-group-id` HTML structure, organizing each mining instance into separate pages within the note. +- Enable `isLapis` to mine dedicated sentence cards. SubMiner sets `IsSentenceCard` to `"x"` and fills the sentence fields for the configured model. +- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits. +- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`. +- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes). ### N+1 Word Highlighting diff --git a/docs/usage.md b/docs/usage.md index 44905b5..f971976 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,11 +1,15 @@ # Usage +> [!IMPORTANT] +> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work. +> See [Yomitan setup](#yomitan-setup) for details. + There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin: -| Approach | Best For | -| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Approach | Best For | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. With default plugin settings, overlay auto-starts visible and playback resumes after annotation readiness. | -| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. | +| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. 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. @@ -147,6 +151,14 @@ secondary-sub-visibility=no `secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead. +### Yomitan setup + +SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed. + +For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --settings` or `SubMiner.AppImage --settings`) and import at least one dictionary in the bundled Yomitan instance. + +If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance. + ### YouTube Playback `subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv. diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 1d2e76b..2d762b1 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { hasExplicitCommand, parseArgs, shouldStartApp } from './args'; +import { hasExplicitCommand, parseArgs, shouldRunSettingsOnlyStartup, shouldStartApp } from './args'; test('parseArgs parses booleans and value flags', () => { const args = parseArgs([ @@ -60,6 +60,28 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(hasExplicitCommand(refreshKnownWords), true); assert.equal(shouldStartApp(refreshKnownWords), false); + const settings = parseArgs(['--settings']); + assert.equal(settings.settings, true); + assert.equal(hasExplicitCommand(settings), true); + assert.equal(shouldStartApp(settings), true); + assert.equal(shouldRunSettingsOnlyStartup(settings), true); + + const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']); + assert.equal(settingsWithOverlay.settings, true); + assert.equal(settingsWithOverlay.toggleVisibleOverlay, true); + assert.equal(shouldRunSettingsOnlyStartup(settingsWithOverlay), false); + + const yomitanAlias = parseArgs(['--yomitan']); + assert.equal(yomitanAlias.settings, true); + assert.equal(hasExplicitCommand(yomitanAlias), true); + assert.equal(shouldStartApp(yomitanAlias), true); + + const help = parseArgs(['--help']); + assert.equal(help.help, true); + assert.equal(hasExplicitCommand(help), true); + assert.equal(shouldStartApp(help), false); + assert.equal(shouldRunSettingsOnlyStartup(help), false); + const anilistStatus = parseArgs(['--anilist-status']); assert.equal(anilistStatus.anilistStatus, true); assert.equal(hasExplicitCommand(anilistStatus), true); diff --git a/src/cli/args.ts b/src/cli/args.ts index 39db2d6..752deb2 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -295,6 +295,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.start || args.toggle || args.toggleVisibleOverlay || + args.settings || args.copySubtitle || args.copySubtitleMultiple || args.mineSentence || @@ -314,6 +315,50 @@ export function shouldStartApp(args: CliArgs): boolean { return false; } +export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { + return ( + args.settings && + !args.background && + !args.start && + !args.stop && + !args.toggle && + !args.toggleVisibleOverlay && + !args.show && + !args.hide && + !args.showVisibleOverlay && + !args.hideVisibleOverlay && + !args.copySubtitle && + !args.copySubtitleMultiple && + !args.mineSentence && + !args.mineSentenceMultiple && + !args.updateLastCardFromClipboard && + !args.refreshKnownWords && + !args.toggleSecondarySub && + !args.triggerFieldGrouping && + !args.triggerSubsync && + !args.markAudioCard && + !args.openRuntimeOptions && + !args.anilistStatus && + !args.anilistLogout && + !args.anilistSetup && + !args.anilistRetryQueue && + !args.jellyfin && + !args.jellyfinLogin && + !args.jellyfinLogout && + !args.jellyfinLibraries && + !args.jellyfinItems && + !args.jellyfinSubtitles && + !args.jellyfinPlay && + !args.jellyfinRemoteAnnounce && + !args.texthooker && + !args.help && + !args.autoStartOverlay && + !args.generateConfig && + !args.backupOverwrite && + !args.debug + ); +} + export function commandNeedsOverlayRuntime(args: CliArgs): boolean { return ( args.toggle || diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 4a0a734..2bbd48b 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -66,6 +66,55 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy ); }); +test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns true', async () => { + const { deps, calls } = makeDeps({ + shouldSkipHeavyStartup: () => true, + reloadConfig: () => calls.push('reloadConfig'), + getResolvedConfig: () => { + calls.push('getResolvedConfig'); + return { + websocket: { enabled: 'auto' }, + secondarySub: {}, + }; + }, + getConfigWarnings: () => { + calls.push('getConfigWarnings'); + return []; + }, + setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`), + initRuntimeOptionsManager: () => calls.push('initRuntimeOptionsManager'), + startBackgroundWarmups: () => calls.push('startBackgroundWarmups'), + loadSubtitlePosition: () => calls.push('loadSubtitlePosition'), + resolveKeybindings: () => calls.push('resolveKeybindings'), + createMpvClient: () => calls.push('createMpvClient'), + logConfigWarning: () => calls.push('logConfigWarning'), + startJellyfinRemoteSession: async () => { + calls.push('startJellyfinRemoteSession'); + }, + createImmersionTracker: () => calls.push('createImmersionTracker'), + handleInitialArgs: () => calls.push('handleInitialArgs'), + }); + + await runAppReadyRuntime(deps); + + assert.equal(calls.includes('reloadConfig'), false); + assert.equal(calls.includes('getResolvedConfig'), false); + assert.equal(calls.includes('getConfigWarnings'), false); + assert.equal(calls.includes('setLogLevel:warn:config'), false); + assert.equal(calls.includes('startBackgroundWarmups'), false); + assert.equal(calls.includes('loadSubtitlePosition'), false); + assert.equal(calls.includes('resolveKeybindings'), false); + assert.equal(calls.includes('createMpvClient'), false); + assert.equal(calls.includes('initRuntimeOptionsManager'), false); + assert.equal(calls.includes('createImmersionTracker'), false); + assert.equal(calls.includes('startJellyfinRemoteSession'), false); + assert.equal(calls.includes('logConfigWarning'), false); + assert.equal(calls.includes('handleInitialArgs'), true); + assert.equal(calls.includes('loadYomitanExtension'), true); + assert.equal(calls[0], 'loadYomitanExtension'); + assert.equal(calls[calls.length - 1], 'handleInitialArgs'); +}); + test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { const { deps, calls } = makeDeps({ startJellyfinRemoteSession: undefined, diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index c7f7122..21df7af 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -121,6 +121,7 @@ export interface AppReadyRuntimeDeps { logDebug?: (message: string) => void; onCriticalConfigErrors?: (errors: string[]) => void; now?: () => number; + shouldSkipHeavyStartup?: () => boolean; } const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [ @@ -169,6 +170,13 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise initializeOverlayRuntime(), handleInitialArgs: () => handleInitialArgs(), + shouldSkipHeavyStartup: () => + Boolean(appState.initialArgs && shouldRunSettingsOnlyStartup(appState.initialArgs)), createImmersionTracker: () => { ensureImmersionTrackerStarted(); }, diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 4829220..1742f48 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -48,6 +48,7 @@ export interface AppReadyRuntimeDepsFactoryInput { onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors']; logDebug?: AppReadyRuntimeDeps['logDebug']; now?: AppReadyRuntimeDeps['now']; + shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup']; } export function createAppLifecycleRuntimeDeps( @@ -103,6 +104,7 @@ export function createAppReadyRuntimeDeps( onCriticalConfigErrors: params.onCriticalConfigErrors, logDebug: params.logDebug, now: params.now, + shouldSkipHeavyStartup: params.shouldSkipHeavyStartup, }; } diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts index aff812b..d0eec25 100644 --- a/src/main/runtime/app-ready-main-deps.ts +++ b/src/main/runtime/app-ready-main-deps.ts @@ -31,5 +31,6 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD onCriticalConfigErrors: deps.onCriticalConfigErrors, logDebug: deps.logDebug, now: deps.now, + shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup, }); }