fix: address latest PR review feedback

This commit is contained in:
2026-03-19 20:06:52 -07:00
parent 9ad3ccfa38
commit 1227706ac9
11 changed files with 306 additions and 33 deletions

View File

@@ -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.**
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![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) [![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) [![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
<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 \

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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);
} }

View File

@@ -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', {});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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;