diff --git a/README.md b/README.md
index 1e6921d..50a795b 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
**Sentence-mine from mpv — look up words, one-key Anki export, immersion tracking.**
[](https://www.gnu.org/licenses/gpl-3.0)
-[]()
+[](https://github.com/ksyasuda/SubMiner)
[](https://docs.subminer.moe)
[](https://aur.archlinux.org/packages/subminer-bin)
@@ -75,6 +75,7 @@ git clone https://aur.archlinux.org/subminer-bin.git && cd subminer-bin && makep
Linux (AppImage)
```bash
+mkdir -p ~/.local/bin
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
&& chmod +x ~/.local/bin/SubMiner.AppImage
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts
index ba3aea5..c926a97 100644
--- a/launcher/commands/command-modules.test.ts
+++ b/launcher/commands/command-modules.test.ts
@@ -212,7 +212,7 @@ test('stats background command launches attached daemon control command with res
]);
});
-test('stats command returns after startup response even if app process stays running', async () => {
+test('stats command waits for attached app exit after startup response', async () => {
const context = createContext();
context.args.stats = true;
const forwarded: string[][] = [];
@@ -246,6 +246,24 @@ test('stats command returns after startup response even if app process stays run
]);
});
+test('stats command throws when attached app exits non-zero after startup response', async () => {
+ const context = createContext();
+ context.args.stats = true;
+
+ await assert.rejects(async () => {
+ await runStatsCommand(context, {
+ createTempDir: () => '/tmp/subminer-stats-test',
+ joinPath: (...parts) => parts.join('/'),
+ runAppCommandAttached: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ return 3;
+ },
+ waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
+ removeDir: () => {},
+ });
+ }, /Stats app exited with status 3\./);
+});
+
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
const context = createContext();
context.args.stats = true;
diff --git a/launcher/commands/stats-command.ts b/launcher/commands/stats-command.ts
index 61ee80e..09c17d2 100644
--- a/launcher/commands/stats-command.ts
+++ b/launcher/commands/stats-command.ts
@@ -123,7 +123,10 @@ export async function runStatsCommand(
if (!startupResult.response.ok) {
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
}
- await attachedExitPromise;
+ const exitStatus = await attachedExitPromise;
+ if (exitStatus !== 0) {
+ throw new Error(`Stats app exited with status ${exitStatus}.`);
+ }
return true;
}
const attachedExitPromiseCleanup = attachedExitPromise;
diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts
index b230dfe..40ea761 100644
--- a/launcher/config/cli-parser-builder.ts
+++ b/launcher/config/cli-parser-builder.ts
@@ -291,6 +291,12 @@ export function parseCliPrograms(
if (normalizedAction && (statsBackground || statsStop)) {
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
}
+ if (
+ normalizedAction !== 'cleanup' &&
+ (options.vocab === true || options.lifetime === true)
+ ) {
+ throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
+ }
if (normalizedAction === 'cleanup') {
statsCleanup = true;
statsCleanupLifetime = options.lifetime === true;
diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts
index f9ced26..d6b0a36 100644
--- a/launcher/mpv.test.ts
+++ b/launcher/mpv.test.ts
@@ -9,6 +9,7 @@ import type { Args } from './types';
import {
cleanupPlaybackSession,
findAppBinary,
+ parseMpvArgString,
runAppCommandCaptureOutput,
shouldResolveAniSkipMetadata,
startOverlay,
@@ -60,6 +61,16 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
}
});
+test('parseMpvArgString preserves empty quoted tokens', () => {
+ assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
+ '--title',
+ '',
+ '--force-media-title',
+ '',
+ '--pause',
+ ]);
+});
+
test('waitForUnixSocketReady returns false when socket never appears', async () => {
const { dir, socketPath } = createTempSocketPath();
try {
diff --git a/launcher/mpv.ts b/launcher/mpv.ts
index b747078..b10a3fa 100644
--- a/launcher/mpv.ts
+++ b/launcher/mpv.ts
@@ -42,6 +42,7 @@ export function parseMpvArgString(input: string): string[] {
const chars = input;
const args: string[] = [];
let current = '';
+ let tokenStarted = false;
let inSingleQuote = false;
let inDoubleQuote = false;
let escaping = false;
@@ -52,6 +53,7 @@ export function parseMpvArgString(input: string): string[] {
const ch = chars[i] || '';
if (escaping) {
current += ch;
+ tokenStarted = true;
escaping = false;
continue;
}
@@ -61,6 +63,7 @@ export function parseMpvArgString(input: string): string[] {
inSingleQuote = false;
} else {
current += ch;
+ tokenStarted = true;
}
continue;
}
@@ -71,6 +74,7 @@ export function parseMpvArgString(input: string): string[] {
escaping = true;
} else {
current += ch;
+ tokenStarted = true;
}
continue;
}
@@ -79,33 +83,40 @@ export function parseMpvArgString(input: string): string[] {
continue;
}
current += ch;
+ tokenStarted = true;
continue;
}
if (ch === '\\') {
if (canEscape(chars[i + 1])) {
escaping = true;
+ tokenStarted = true;
} else {
current += ch;
+ tokenStarted = true;
}
continue;
}
if (ch === "'") {
+ tokenStarted = true;
inSingleQuote = true;
continue;
}
if (ch === '"') {
+ tokenStarted = true;
inDoubleQuote = true;
continue;
}
if (/\s/.test(ch)) {
- if (current) {
+ if (tokenStarted) {
args.push(current);
current = '';
+ tokenStarted = false;
}
continue;
}
current += ch;
+ tokenStarted = true;
}
if (escaping) {
@@ -114,7 +125,7 @@ export function parseMpvArgString(input: string): string[] {
if (inSingleQuote || inDoubleQuote) {
fail('Could not parse mpv args: unmatched quote');
}
- if (current) {
+ if (tokenStarted) {
args.push(current);
}
diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts
index 863bd1a..907c7d0 100644
--- a/launcher/parse-args.test.ts
+++ b/launcher/parse-args.test.ts
@@ -2,6 +2,34 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs } from './config';
+class ExitSignal extends Error {
+ code: number;
+
+ constructor(code: number) {
+ super(`exit:${code}`);
+ this.code = code;
+ }
+}
+
+function withProcessExitIntercept(callback: () => void): ExitSignal {
+ const originalExit = process.exit;
+ try {
+ process.exit = ((code?: number) => {
+ throw new ExitSignal(code ?? 0);
+ }) as typeof process.exit;
+ callback();
+ } catch (error) {
+ if (error instanceof ExitSignal) {
+ return error;
+ }
+ throw error;
+ } finally {
+ process.exit = originalExit;
+ }
+
+ throw new Error('expected parseArgs to exit');
+}
+
test('parseArgs captures passthrough args for app subcommand', () => {
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
@@ -119,6 +147,15 @@ test('parseArgs maps lifetime stats cleanup flag', () => {
assert.equal(parsed.statsCleanupLifetime, true);
});
+test('parseArgs rejects cleanup-only stats flags without cleanup action', () => {
+ const error = withProcessExitIntercept(() => {
+ parseArgs(['stats', '--vocab'], 'subminer', {});
+ });
+
+ assert.equal(error.code, 1);
+ assert.match(error.message, /exit:1/);
+});
+
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
const parsed = parseArgs(['stats', 'rebuild'], 'subminer', {});
diff --git a/launcher/picker.test.ts b/launcher/picker.test.ts
index dc4e1ee..fd47a62 100644
--- a/launcher/picker.test.ts
+++ b/launcher/picker.test.ts
@@ -14,6 +14,20 @@ function makeFile(filePath: string): void {
fs.writeFileSync(filePath, '/* theme */');
}
+function withPlatform(platform: NodeJS.Platform, callback: () => T): T {
+ const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
+ Object.defineProperty(process, 'platform', {
+ value: platform,
+ });
+ try {
+ return callback();
+ } finally {
+ if (originalDescriptor) {
+ Object.defineProperty(process, 'platform', originalDescriptor);
+ }
+ }
+}
+
test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when it exists', () => {
const originalExistsSync = fs.existsSync;
const targetPath = `/usr/local/share/SubMiner/themes/${ROFI_THEME_FILE}`;
@@ -24,7 +38,7 @@ test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when
return false;
};
- const result = findRofiTheme('/usr/local/bin/subminer');
+ const result = withPlatform('linux', () => findRofiTheme('/usr/local/bin/subminer'));
assert.equal(result, targetPath);
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -44,7 +58,7 @@ test('findRofiTheme resolves /usr/share/SubMiner/themes/subminer.rasi when /usr/
return false;
};
- const result = findRofiTheme('/usr/bin/subminer');
+ const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
assert.equal(result, sharePath);
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -60,10 +74,14 @@ test('findRofiTheme resolves XDG_DATA_HOME/SubMiner/themes/subminer.rasi when se
const themePath = path.join(baseDir, `SubMiner/themes/${ROFI_THEME_FILE}`);
makeFile(themePath);
- const result = findRofiTheme('/usr/bin/subminer');
+ const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
assert.equal(result, themePath);
} finally {
- process.env.XDG_DATA_HOME = originalXdgDataHome;
+ if (originalXdgDataHome !== undefined) {
+ process.env.XDG_DATA_HOME = originalXdgDataHome;
+ } else {
+ delete process.env.XDG_DATA_HOME;
+ }
fs.rmSync(baseDir, { recursive: true, force: true });
}
});
@@ -78,7 +96,7 @@ test('findRofiTheme resolves ~/.local/share/SubMiner/themes/subminer.rasi when X
const themePath = path.join(baseDir, `.local/share/SubMiner/themes/${ROFI_THEME_FILE}`);
makeFile(themePath);
- const result = findRofiTheme('/usr/bin/subminer');
+ const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
assert.equal(result, themePath);
} finally {
os.homedir = originalHomedir;
diff --git a/src/anki-integration.ts b/src/anki-integration.ts
index 9752887..8b30355 100644
--- a/src/anki-integration.ts
+++ b/src/anki-integration.ts
@@ -1048,10 +1048,6 @@ export class AnkiIntegration {
return getConfiguredWordFieldCandidates(this.config);
}
- private getPreferredWordValue(fields: Record): string {
- return getPreferredWordValueFromExtractedFields(fields, this.config);
- }
-
private async getAnimatedImageLeadInSeconds(noteInfo: NoteInfo): Promise {
return resolveAnimatedImageLeadInSeconds({
config: this.config,
diff --git a/src/anki-integration/known-word-cache.test.ts b/src/anki-integration/known-word-cache.test.ts
index d38afb6..b286d30 100644
--- a/src/anki-integration/known-word-cache.test.ts
+++ b/src/anki-integration/known-word-cache.test.ts
@@ -7,6 +7,21 @@ import path from 'node:path';
import type { AnkiConnectConfig } from '../types';
import { KnownWordCacheManager } from './known-word-cache';
+async function waitForCondition(
+ condition: () => boolean,
+ timeoutMs = 500,
+ intervalMs = 10,
+): Promise {
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < timeoutMs) {
+ if (condition()) {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ }
+ throw new Error('Timed out waiting for condition');
+}
+
function createKnownWordCacheHarness(config: AnkiConnectConfig): {
manager: KnownWordCacheManager;
calls: {
@@ -17,6 +32,7 @@ function createKnownWordCacheHarness(config: AnkiConnectConfig): {
clientState: {
findNotesResult: number[];
notesInfoResult: Array<{ noteId: number; fields: Record }>;
+ findNotesByQuery: Map;
};
cleanup: () => void;
} {
@@ -29,11 +45,15 @@ function createKnownWordCacheHarness(config: AnkiConnectConfig): {
const clientState = {
findNotesResult: [] as number[],
notesInfoResult: [] as Array<{ noteId: number; fields: Record }>,
+ findNotesByQuery: new Map(),
};
const manager = new KnownWordCacheManager({
client: {
- findNotes: async () => {
+ findNotes: async (query) => {
calls.findNotes += 1;
+ if (clientState.findNotesByQuery.has(query)) {
+ return clientState.findNotesByQuery.get(query) ?? [];
+ }
return clientState.findNotesResult;
},
notesInfo: async (noteIds) => {
@@ -57,10 +77,11 @@ function createKnownWordCacheHarness(config: AnkiConnectConfig): {
};
}
-test('KnownWordCacheManager startLifecycle loads persisted cache without immediate rebuild', () => {
+test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without immediate refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
+ refreshMinutes: 60,
},
};
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
@@ -71,7 +92,7 @@ test('KnownWordCacheManager startLifecycle loads persisted cache without immedia
JSON.stringify({
version: 2,
refreshedAtMs: Date.now(),
- scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":""}',
+ scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
words: ['猫'],
notes: {
'1': ['猫'],
@@ -81,6 +102,7 @@ test('KnownWordCacheManager startLifecycle loads persisted cache without immedia
);
manager.startLifecycle();
+ await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(calls.findNotes, 0);
@@ -91,6 +113,54 @@ test('KnownWordCacheManager startLifecycle loads persisted cache without immedia
}
});
+test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted cache', async () => {
+ const config: AnkiConnectConfig = {
+ fields: {
+ word: 'Word',
+ },
+ knownWords: {
+ highlightEnabled: true,
+ refreshMinutes: 1,
+ },
+ };
+ const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
+
+ try {
+ fs.writeFileSync(
+ statePath,
+ JSON.stringify({
+ version: 2,
+ refreshedAtMs: Date.now() - 61_000,
+ scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
+ words: ['猫'],
+ notes: {
+ '1': ['猫'],
+ },
+ }),
+ 'utf-8',
+ );
+
+ clientState.findNotesResult = [1];
+ clientState.notesInfoResult = [
+ {
+ noteId: 1,
+ fields: {
+ Word: { value: '犬' },
+ },
+ },
+ ];
+
+ manager.startLifecycle();
+ await waitForCondition(() => calls.findNotes === 1 && calls.notesInfo === 1);
+
+ assert.equal(manager.isKnownWord('猫'), false);
+ assert.equal(manager.isKnownWord('犬'), true);
+ } finally {
+ manager.stopLifecycle();
+ cleanup();
+ }
+});
+
test('KnownWordCacheManager invalidates persisted cache when fields.word changes', () => {
const config: AnkiConnectConfig = {
deck: 'Mining',
@@ -235,6 +305,49 @@ test('KnownWordCacheManager invalidates persisted cache when per-deck fields cha
}
});
+test('KnownWordCacheManager preserves deck-specific field mappings during refresh', async () => {
+ const config: AnkiConnectConfig = {
+ knownWords: {
+ highlightEnabled: true,
+ decks: {
+ Mining: ['Expression'],
+ Reading: ['Word'],
+ },
+ },
+ };
+ const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
+
+ try {
+ clientState.findNotesByQuery.set('deck:"Mining"', [1]);
+ clientState.findNotesByQuery.set('deck:"Reading"', [2]);
+ clientState.notesInfoResult = [
+ {
+ noteId: 1,
+ fields: {
+ Expression: { value: '猫' },
+ Word: { value: 'should-not-count' },
+ },
+ },
+ {
+ noteId: 2,
+ fields: {
+ Word: { value: '犬' },
+ Expression: { value: 'also-ignored' },
+ },
+ },
+ ];
+
+ await manager.refresh(true);
+
+ assert.equal(manager.isKnownWord('猫'), true);
+ assert.equal(manager.isKnownWord('犬'), true);
+ assert.equal(manager.isKnownWord('should-not-count'), false);
+ assert.equal(manager.isKnownWord('also-ignored'), false);
+ } finally {
+ cleanup();
+ }
+});
+
test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => {
const config: AnkiConnectConfig = {
knownWords: {
diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts
index d29463c..ee4dc60 100644
--- a/src/anki-integration/known-word-cache.ts
+++ b/src/anki-integration/known-word-cache.ts
@@ -98,6 +98,11 @@ interface KnownWordCacheDeps {
showStatusNotification: (message: string) => void;
}
+type KnownWordQueryScope = {
+ query: string;
+ fields: string[];
+};
+
export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0;
private knownWordsStateKey = '';
@@ -219,18 +224,11 @@ export class KnownWordCacheManager {
this.isRefreshingKnownWords = true;
try {
- const query = this.buildKnownWordsQuery();
- log.debug('Refreshing known-word cache', `query=${query}`);
- const noteIds = (await this.deps.client.findNotes(query, {
- maxRetries: 0,
- })) as number[];
-
- const currentNoteIds = Array.from(
- new Set(noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)),
- ).sort((a, b) => a - b);
+ const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
+ const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
if (this.noteWordsById.size === 0) {
- await this.rebuildFromCurrentNotes(currentNoteIds);
+ await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
} else {
const currentNoteIdSet = new Set(currentNoteIds);
for (const noteId of Array.from(this.noteWordsById.keys())) {
@@ -244,7 +242,10 @@ export class KnownWordCacheManager {
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(
noteInfo.noteId,
- this.extractNormalizedKnownWordsFromNoteInfo(noteInfo),
+ this.extractNormalizedKnownWordsFromNoteInfo(
+ noteInfo,
+ noteFieldsById.get(noteInfo.noteId),
+ ),
);
}
}
@@ -307,6 +308,31 @@ export class KnownWordCacheManager {
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
}
+ private getKnownWordQueryScopes(): KnownWordQueryScope[] {
+ const configuredDecks = this.deps.getConfig().knownWords?.decks;
+ if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
+ const scopes: KnownWordQueryScope[] = [];
+ for (const [deckName, fields] of Object.entries(configuredDecks)) {
+ const trimmedDeckName = deckName.trim();
+ if (!trimmedDeckName) {
+ continue;
+ }
+ const normalizedFields = Array.isArray(fields)
+ ? [...new Set(fields.map(String).map((field) => field.trim()).filter(Boolean))]
+ : [];
+ scopes.push({
+ query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
+ fields: normalizedFields.length > 0 ? normalizedFields : this.getConfiguredFields(),
+ });
+ }
+ if (scopes.length > 0) {
+ return scopes;
+ }
+ }
+
+ return [{ query: this.buildKnownWordsQuery(), fields: this.getConfiguredFields() }];
+ }
+
private buildKnownWordsQuery(): string {
const decks = this.getKnownWordDecks();
if (decks.length === 0) {
@@ -338,6 +364,31 @@ export class KnownWordCacheManager {
return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
}
+ private async fetchKnownWordNoteFieldsById(): Promise