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.** [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -[![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-informational)]() +[![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-informational)](https://github.com/ksyasuda/SubMiner) [![Docs](https://img.shields.io/badge/docs-docs.subminer.moe-blueviolet)](https://docs.subminer.moe) [![AUR](https://img.shields.io/aur/version/subminer-bin)](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> { + const scopes = this.getKnownWordQueryScopes(); + const noteFieldsById = new Map(); + log.debug('Refreshing known-word cache', `queries=${scopes.map((scope) => scope.query).join(' | ')}`); + + for (const scope of scopes) { + const noteIds = (await this.deps.client.findNotes(scope.query, { + maxRetries: 0, + })) as number[]; + + for (const noteId of noteIds) { + if (!Number.isInteger(noteId) || noteId <= 0) { + continue; + } + const existingFields = noteFieldsById.get(noteId) ?? []; + noteFieldsById.set( + noteId, + [...new Set([...existingFields, ...scope.fields])], + ); + } + } + + return noteFieldsById; + } + private scheduleKnownWordRefreshLifecycle(): void { const refreshIntervalMs = this.getKnownWordRefreshIntervalMs(); const scheduleInterval = () => { @@ -366,7 +417,10 @@ export class KnownWordCacheManager { return Math.max(0, remainingMs); } - private async rebuildFromCurrentNotes(noteIds: number[]): Promise { + private async rebuildFromCurrentNotes( + noteIds: number[], + noteFieldsById: Map, + ): Promise { this.clearInMemoryState(); if (noteIds.length === 0) { return; @@ -374,7 +428,10 @@ export class KnownWordCacheManager { const noteInfos = await this.fetchKnownWordNotesInfo(noteIds); for (const noteInfo of noteInfos) { - this.replaceNoteSnapshot(noteInfo.noteId, this.extractNormalizedKnownWordsFromNoteInfo(noteInfo)); + this.replaceNoteSnapshot( + noteInfo.noteId, + this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)), + ); } } @@ -562,10 +619,12 @@ export class KnownWordCacheManager { return true; } - private extractNormalizedKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] { + private extractNormalizedKnownWordsFromNoteInfo( + noteInfo: KnownWordCacheNoteInfo, + preferredFields = this.getConfiguredFields(), + ): string[] { const words: string[] = []; - const configuredFields = this.getConfiguredFields(); - for (const preferredField of configuredFields) { + for (const preferredField of preferredFields) { const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField); if (!fieldName) continue;