docs: document Kiku/Lapis integration in config and anki pages

This commit is contained in:
2026-03-01 01:08:30 -08:00
parent 73e70b4395
commit c8b65a01f6
11 changed files with 174 additions and 11 deletions

View File

@@ -14,7 +14,7 @@
<div align="center"> <div align="center">
[![SubMiner demo (GIF preview)](./assets/minecard.gif)](./assets/minecard.mp4) [![SubMiner demo (Animated preview)](./assets/minecard.webp)](./assets/minecard.mp4)
</div> </div>
@@ -69,7 +69,7 @@ mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/c
### 3. Set up Yomitan Dictionaries ### 3. Set up Yomitan Dictionaries
```bash ```bash
subminer app --start --yomitan subminer app --yomitan
``` ```
### 4. Mine ### 4. Mine

View File

@@ -1,6 +1,7 @@
# Anki Integration # 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. 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 ## Prerequisites

View File

@@ -97,6 +97,7 @@ The configuration file includes several main sections:
**Anki Integration** **Anki Integration**
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media - [**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 - [**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 - [**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`. | | `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`) | | `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 ### N+1 Word Highlighting

View File

@@ -1,11 +1,15 @@
# Usage # 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: 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. | | **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. 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. `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 ### YouTube Playback
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv. `subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv.

View File

@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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', () => { test('parseArgs parses booleans and value flags', () => {
const args = parseArgs([ const args = parseArgs([
@@ -60,6 +60,28 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(refreshKnownWords), true); assert.equal(hasExplicitCommand(refreshKnownWords), true);
assert.equal(shouldStartApp(refreshKnownWords), false); 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']); const anilistStatus = parseArgs(['--anilist-status']);
assert.equal(anilistStatus.anilistStatus, true); assert.equal(anilistStatus.anilistStatus, true);
assert.equal(hasExplicitCommand(anilistStatus), true); assert.equal(hasExplicitCommand(anilistStatus), true);

View File

@@ -295,6 +295,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.start || args.start ||
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.settings ||
args.copySubtitle || args.copySubtitle ||
args.copySubtitleMultiple || args.copySubtitleMultiple ||
args.mineSentence || args.mineSentence ||
@@ -314,6 +315,50 @@ export function shouldStartApp(args: CliArgs): boolean {
return false; 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 { export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
return ( return (
args.toggle || args.toggle ||

View File

@@ -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 () => { test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
const { deps, calls } = makeDeps({ const { deps, calls } = makeDeps({
startJellyfinRemoteSession: undefined, startJellyfinRemoteSession: undefined,

View File

@@ -121,6 +121,7 @@ export interface AppReadyRuntimeDeps {
logDebug?: (message: string) => void; logDebug?: (message: string) => void;
onCriticalConfigErrors?: (errors: string[]) => void; onCriticalConfigErrors?: (errors: string[]) => void;
now?: () => number; now?: () => number;
shouldSkipHeavyStartup?: () => boolean;
} }
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [ const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
@@ -169,6 +170,13 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const startupStartedAtMs = now(); const startupStartedAtMs = now();
deps.logDebug?.('App-ready critical path started.'); deps.logDebug?.('App-ready critical path started.');
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.handleInitialArgs();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
return;
}
deps.reloadConfig(); deps.reloadConfig();
const config = deps.getResolvedConfig(); const config = deps.getResolvedConfig();
const criticalConfigErrors = getStartupCriticalConfigErrors(config); const criticalConfigErrors = getStartupCriticalConfigErrors(config);

View File

@@ -101,7 +101,12 @@ import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { RuntimeOptionsManager } from './runtime-options'; import { RuntimeOptionsManager } from './runtime-options';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils'; import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
import { createLogger, setLogLevel, type LogLevelSource } from './logger'; import { createLogger, setLogLevel, type LogLevelSource } from './logger';
import { commandNeedsOverlayRuntime, parseArgs, shouldStartApp } from './cli/args'; import {
commandNeedsOverlayRuntime,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldStartApp,
} from './cli/args';
import type { CliArgs, CliCommandSource } from './cli/args'; import type { CliArgs, CliCommandSource } from './cli/args';
import { printHelp } from './cli/help'; import { printHelp } from './cli/help';
import { import {
@@ -2163,6 +2168,8 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), : configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(), handleInitialArgs: () => handleInitialArgs(),
shouldSkipHeavyStartup: () =>
Boolean(appState.initialArgs && shouldRunSettingsOnlyStartup(appState.initialArgs)),
createImmersionTracker: () => { createImmersionTracker: () => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
}, },

View File

@@ -48,6 +48,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors']; onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
logDebug?: AppReadyRuntimeDeps['logDebug']; logDebug?: AppReadyRuntimeDeps['logDebug'];
now?: AppReadyRuntimeDeps['now']; now?: AppReadyRuntimeDeps['now'];
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
} }
export function createAppLifecycleRuntimeDeps( export function createAppLifecycleRuntimeDeps(
@@ -103,6 +104,7 @@ export function createAppReadyRuntimeDeps(
onCriticalConfigErrors: params.onCriticalConfigErrors, onCriticalConfigErrors: params.onCriticalConfigErrors,
logDebug: params.logDebug, logDebug: params.logDebug,
now: params.now, now: params.now,
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
}; };
} }

View File

@@ -31,5 +31,6 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
onCriticalConfigErrors: deps.onCriticalConfigErrors, onCriticalConfigErrors: deps.onCriticalConfigErrors,
logDebug: deps.logDebug, logDebug: deps.logDebug,
now: deps.now, now: deps.now,
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
}); });
} }