mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
fix: address latest PR review feedback
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
**Sentence-mine from mpv — look up words, one-key Anki export, immersion tracking.**
|
**Sentence-mine from mpv — look up words, one-key Anki export, immersion tracking.**
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[]()
|
[](https://github.com/ksyasuda/SubMiner)
|
||||||
[](https://docs.subminer.moe)
|
[](https://docs.subminer.moe)
|
||||||
[](https://aur.archlinux.org/packages/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
|
|||||||
<summary><b>Linux (AppImage)</b></summary>
|
<summary><b>Linux (AppImage)</b></summary>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
mkdir -p ~/.local/bin
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
||||||
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
||||||
|
|||||||
@@ -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();
|
const context = createContext();
|
||||||
context.args.stats = true;
|
context.args.stats = true;
|
||||||
const forwarded: string[][] = [];
|
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 () => {
|
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.stats = true;
|
context.args.stats = true;
|
||||||
|
|||||||
@@ -123,7 +123,10 @@ export async function runStatsCommand(
|
|||||||
if (!startupResult.response.ok) {
|
if (!startupResult.response.ok) {
|
||||||
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
const attachedExitPromiseCleanup = attachedExitPromise;
|
const attachedExitPromiseCleanup = attachedExitPromise;
|
||||||
|
|||||||
@@ -291,6 +291,12 @@ export function parseCliPrograms(
|
|||||||
if (normalizedAction && (statsBackground || statsStop)) {
|
if (normalizedAction && (statsBackground || statsStop)) {
|
||||||
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
|
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') {
|
if (normalizedAction === 'cleanup') {
|
||||||
statsCleanup = true;
|
statsCleanup = true;
|
||||||
statsCleanupLifetime = options.lifetime === true;
|
statsCleanupLifetime = options.lifetime === true;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { Args } from './types';
|
|||||||
import {
|
import {
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
findAppBinary,
|
findAppBinary,
|
||||||
|
parseMpvArgString,
|
||||||
runAppCommandCaptureOutput,
|
runAppCommandCaptureOutput,
|
||||||
shouldResolveAniSkipMetadata,
|
shouldResolveAniSkipMetadata,
|
||||||
startOverlay,
|
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 () => {
|
test('waitForUnixSocketReady returns false when socket never appears', async () => {
|
||||||
const { dir, socketPath } = createTempSocketPath();
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function parseMpvArgString(input: string): string[] {
|
|||||||
const chars = input;
|
const chars = input;
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
|
let tokenStarted = false;
|
||||||
let inSingleQuote = false;
|
let inSingleQuote = false;
|
||||||
let inDoubleQuote = false;
|
let inDoubleQuote = false;
|
||||||
let escaping = false;
|
let escaping = false;
|
||||||
@@ -52,6 +53,7 @@ export function parseMpvArgString(input: string): string[] {
|
|||||||
const ch = chars[i] || '';
|
const ch = chars[i] || '';
|
||||||
if (escaping) {
|
if (escaping) {
|
||||||
current += ch;
|
current += ch;
|
||||||
|
tokenStarted = true;
|
||||||
escaping = false;
|
escaping = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,7 @@ export function parseMpvArgString(input: string): string[] {
|
|||||||
inSingleQuote = false;
|
inSingleQuote = false;
|
||||||
} else {
|
} else {
|
||||||
current += ch;
|
current += ch;
|
||||||
|
tokenStarted = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -71,6 +74,7 @@ export function parseMpvArgString(input: string): string[] {
|
|||||||
escaping = true;
|
escaping = true;
|
||||||
} else {
|
} else {
|
||||||
current += ch;
|
current += ch;
|
||||||
|
tokenStarted = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -79,33 +83,40 @@ export function parseMpvArgString(input: string): string[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
current += ch;
|
current += ch;
|
||||||
|
tokenStarted = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ch === '\\') {
|
if (ch === '\\') {
|
||||||
if (canEscape(chars[i + 1])) {
|
if (canEscape(chars[i + 1])) {
|
||||||
escaping = true;
|
escaping = true;
|
||||||
|
tokenStarted = true;
|
||||||
} else {
|
} else {
|
||||||
current += ch;
|
current += ch;
|
||||||
|
tokenStarted = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (ch === "'") {
|
if (ch === "'") {
|
||||||
|
tokenStarted = true;
|
||||||
inSingleQuote = true;
|
inSingleQuote = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (ch === '"') {
|
if (ch === '"') {
|
||||||
|
tokenStarted = true;
|
||||||
inDoubleQuote = true;
|
inDoubleQuote = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/\s/.test(ch)) {
|
if (/\s/.test(ch)) {
|
||||||
if (current) {
|
if (tokenStarted) {
|
||||||
args.push(current);
|
args.push(current);
|
||||||
current = '';
|
current = '';
|
||||||
|
tokenStarted = false;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
current += ch;
|
current += ch;
|
||||||
|
tokenStarted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (escaping) {
|
if (escaping) {
|
||||||
@@ -114,7 +125,7 @@ export function parseMpvArgString(input: string): string[] {
|
|||||||
if (inSingleQuote || inDoubleQuote) {
|
if (inSingleQuote || inDoubleQuote) {
|
||||||
fail('Could not parse mpv args: unmatched quote');
|
fail('Could not parse mpv args: unmatched quote');
|
||||||
}
|
}
|
||||||
if (current) {
|
if (tokenStarted) {
|
||||||
args.push(current);
|
args.push(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,34 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { parseArgs } from './config';
|
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', () => {
|
test('parseArgs captures passthrough args for app subcommand', () => {
|
||||||
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
|
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);
|
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', () => {
|
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
|
||||||
const parsed = parseArgs(['stats', 'rebuild'], 'subminer', {});
|
const parsed = parseArgs(['stats', 'rebuild'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ function makeFile(filePath: string): void {
|
|||||||
fs.writeFileSync(filePath, '/* theme */');
|
fs.writeFileSync(filePath, '/* theme */');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withPlatform<T>(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', () => {
|
test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when it exists', () => {
|
||||||
const originalExistsSync = fs.existsSync;
|
const originalExistsSync = fs.existsSync;
|
||||||
const targetPath = `/usr/local/share/SubMiner/themes/${ROFI_THEME_FILE}`;
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = findRofiTheme('/usr/local/bin/subminer');
|
const result = withPlatform('linux', () => findRofiTheme('/usr/local/bin/subminer'));
|
||||||
assert.equal(result, targetPath);
|
assert.equal(result, targetPath);
|
||||||
} finally {
|
} finally {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = findRofiTheme('/usr/bin/subminer');
|
const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
|
||||||
assert.equal(result, sharePath);
|
assert.equal(result, sharePath);
|
||||||
} finally {
|
} finally {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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}`);
|
const themePath = path.join(baseDir, `SubMiner/themes/${ROFI_THEME_FILE}`);
|
||||||
makeFile(themePath);
|
makeFile(themePath);
|
||||||
|
|
||||||
const result = findRofiTheme('/usr/bin/subminer');
|
const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
|
||||||
assert.equal(result, themePath);
|
assert.equal(result, themePath);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (originalXdgDataHome !== undefined) {
|
||||||
process.env.XDG_DATA_HOME = originalXdgDataHome;
|
process.env.XDG_DATA_HOME = originalXdgDataHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.XDG_DATA_HOME;
|
||||||
|
}
|
||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
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}`);
|
const themePath = path.join(baseDir, `.local/share/SubMiner/themes/${ROFI_THEME_FILE}`);
|
||||||
makeFile(themePath);
|
makeFile(themePath);
|
||||||
|
|
||||||
const result = findRofiTheme('/usr/bin/subminer');
|
const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
|
||||||
assert.equal(result, themePath);
|
assert.equal(result, themePath);
|
||||||
} finally {
|
} finally {
|
||||||
os.homedir = originalHomedir;
|
os.homedir = originalHomedir;
|
||||||
|
|||||||
@@ -1048,10 +1048,6 @@ export class AnkiIntegration {
|
|||||||
return getConfiguredWordFieldCandidates(this.config);
|
return getConfiguredWordFieldCandidates(this.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPreferredWordValue(fields: Record<string, string>): string {
|
|
||||||
return getPreferredWordValueFromExtractedFields(fields, this.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getAnimatedImageLeadInSeconds(noteInfo: NoteInfo): Promise<number> {
|
private async getAnimatedImageLeadInSeconds(noteInfo: NoteInfo): Promise<number> {
|
||||||
return resolveAnimatedImageLeadInSeconds({
|
return resolveAnimatedImageLeadInSeconds({
|
||||||
config: this.config,
|
config: this.config,
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ import path from 'node:path';
|
|||||||
import type { AnkiConnectConfig } from '../types';
|
import type { AnkiConnectConfig } from '../types';
|
||||||
import { KnownWordCacheManager } from './known-word-cache';
|
import { KnownWordCacheManager } from './known-word-cache';
|
||||||
|
|
||||||
|
async function waitForCondition(
|
||||||
|
condition: () => boolean,
|
||||||
|
timeoutMs = 500,
|
||||||
|
intervalMs = 10,
|
||||||
|
): Promise<void> {
|
||||||
|
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): {
|
function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
||||||
manager: KnownWordCacheManager;
|
manager: KnownWordCacheManager;
|
||||||
calls: {
|
calls: {
|
||||||
@@ -17,6 +32,7 @@ function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
|||||||
clientState: {
|
clientState: {
|
||||||
findNotesResult: number[];
|
findNotesResult: number[];
|
||||||
notesInfoResult: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
notesInfoResult: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||||
|
findNotesByQuery: Map<string, number[]>;
|
||||||
};
|
};
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
} {
|
} {
|
||||||
@@ -29,11 +45,15 @@ function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
|||||||
const clientState = {
|
const clientState = {
|
||||||
findNotesResult: [] as number[],
|
findNotesResult: [] as number[],
|
||||||
notesInfoResult: [] as Array<{ noteId: number; fields: Record<string, { value: string }> }>,
|
notesInfoResult: [] as Array<{ noteId: number; fields: Record<string, { value: string }> }>,
|
||||||
|
findNotesByQuery: new Map<string, number[]>(),
|
||||||
};
|
};
|
||||||
const manager = new KnownWordCacheManager({
|
const manager = new KnownWordCacheManager({
|
||||||
client: {
|
client: {
|
||||||
findNotes: async () => {
|
findNotes: async (query) => {
|
||||||
calls.findNotes += 1;
|
calls.findNotes += 1;
|
||||||
|
if (clientState.findNotesByQuery.has(query)) {
|
||||||
|
return clientState.findNotesByQuery.get(query) ?? [];
|
||||||
|
}
|
||||||
return clientState.findNotesResult;
|
return clientState.findNotesResult;
|
||||||
},
|
},
|
||||||
notesInfo: async (noteIds) => {
|
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 = {
|
const config: AnkiConnectConfig = {
|
||||||
knownWords: {
|
knownWords: {
|
||||||
highlightEnabled: true,
|
highlightEnabled: true,
|
||||||
|
refreshMinutes: 60,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||||
@@ -71,7 +92,7 @@ test('KnownWordCacheManager startLifecycle loads persisted cache without immedia
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: Date.now(),
|
refreshedAtMs: Date.now(),
|
||||||
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":""}',
|
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
@@ -81,6 +102,7 @@ test('KnownWordCacheManager startLifecycle loads persisted cache without immedia
|
|||||||
);
|
);
|
||||||
|
|
||||||
manager.startLifecycle();
|
manager.startLifecycle();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
|
||||||
assert.equal(manager.isKnownWord('猫'), true);
|
assert.equal(manager.isKnownWord('猫'), true);
|
||||||
assert.equal(calls.findNotes, 0);
|
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', () => {
|
test('KnownWordCacheManager invalidates persisted cache when fields.word changes', () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
deck: 'Mining',
|
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', () => {
|
test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
knownWords: {
|
knownWords: {
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ interface KnownWordCacheDeps {
|
|||||||
showStatusNotification: (message: string) => void;
|
showStatusNotification: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KnownWordQueryScope = {
|
||||||
|
query: string;
|
||||||
|
fields: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export class KnownWordCacheManager {
|
export class KnownWordCacheManager {
|
||||||
private knownWordsLastRefreshedAtMs = 0;
|
private knownWordsLastRefreshedAtMs = 0;
|
||||||
private knownWordsStateKey = '';
|
private knownWordsStateKey = '';
|
||||||
@@ -219,18 +224,11 @@ export class KnownWordCacheManager {
|
|||||||
|
|
||||||
this.isRefreshingKnownWords = true;
|
this.isRefreshingKnownWords = true;
|
||||||
try {
|
try {
|
||||||
const query = this.buildKnownWordsQuery();
|
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
||||||
log.debug('Refreshing known-word cache', `query=${query}`);
|
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
||||||
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);
|
|
||||||
|
|
||||||
if (this.noteWordsById.size === 0) {
|
if (this.noteWordsById.size === 0) {
|
||||||
await this.rebuildFromCurrentNotes(currentNoteIds);
|
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
|
||||||
} else {
|
} else {
|
||||||
const currentNoteIdSet = new Set(currentNoteIds);
|
const currentNoteIdSet = new Set(currentNoteIds);
|
||||||
for (const noteId of Array.from(this.noteWordsById.keys())) {
|
for (const noteId of Array.from(this.noteWordsById.keys())) {
|
||||||
@@ -244,7 +242,10 @@ export class KnownWordCacheManager {
|
|||||||
for (const noteInfo of noteInfos) {
|
for (const noteInfo of noteInfos) {
|
||||||
this.replaceNoteSnapshot(
|
this.replaceNoteSnapshot(
|
||||||
noteInfo.noteId,
|
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'])];
|
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 {
|
private buildKnownWordsQuery(): string {
|
||||||
const decks = this.getKnownWordDecks();
|
const decks = this.getKnownWordDecks();
|
||||||
if (decks.length === 0) {
|
if (decks.length === 0) {
|
||||||
@@ -338,6 +364,31 @@ export class KnownWordCacheManager {
|
|||||||
return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
|
return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchKnownWordNoteFieldsById(): Promise<Map<number, string[]>> {
|
||||||
|
const scopes = this.getKnownWordQueryScopes();
|
||||||
|
const noteFieldsById = new Map<number, string[]>();
|
||||||
|
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 {
|
private scheduleKnownWordRefreshLifecycle(): void {
|
||||||
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
|
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
|
||||||
const scheduleInterval = () => {
|
const scheduleInterval = () => {
|
||||||
@@ -366,7 +417,10 @@ export class KnownWordCacheManager {
|
|||||||
return Math.max(0, remainingMs);
|
return Math.max(0, remainingMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async rebuildFromCurrentNotes(noteIds: number[]): Promise<void> {
|
private async rebuildFromCurrentNotes(
|
||||||
|
noteIds: number[],
|
||||||
|
noteFieldsById: Map<number, string[]>,
|
||||||
|
): Promise<void> {
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
if (noteIds.length === 0) {
|
if (noteIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -374,7 +428,10 @@ export class KnownWordCacheManager {
|
|||||||
|
|
||||||
const noteInfos = await this.fetchKnownWordNotesInfo(noteIds);
|
const noteInfos = await this.fetchKnownWordNotesInfo(noteIds);
|
||||||
for (const noteInfo of noteInfos) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractNormalizedKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
|
private extractNormalizedKnownWordsFromNoteInfo(
|
||||||
|
noteInfo: KnownWordCacheNoteInfo,
|
||||||
|
preferredFields = this.getConfiguredFields(),
|
||||||
|
): string[] {
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
const configuredFields = this.getConfiguredFields();
|
for (const preferredField of preferredFields) {
|
||||||
for (const preferredField of configuredFields) {
|
|
||||||
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
|
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
|
||||||
if (!fieldName) continue;
|
if (!fieldName) continue;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user