mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Switch known-word cache to incremental sync and doctor refresh
- Load persisted known-word cache on startup; reconcile adds/deletes/edits on timed sync - Add `knownWords.addMinedWordsImmediately` (default `true`) for immediate mined-word updates - Route full rebuild to explicit `subminer doctor --refresh-known-words` and expand tests/docs
This commit is contained in:
50
src/anki-connect.test.ts
Normal file
50
src/anki-connect.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
|
||||
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: {
|
||||
defaults: {
|
||||
httpAgent?: { options?: { keepAlive?: boolean } };
|
||||
httpsAgent?: { options?: { keepAlive?: boolean } };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(client.client.defaults.httpAgent?.options?.keepAlive, false);
|
||||
assert.equal(client.client.defaults.httpsAgent?.options?.keepAlive, false);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient includes action name in retry logs', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: unknown, options: unknown) => Promise<unknown> };
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
};
|
||||
let shouldFail = true;
|
||||
client.client = {
|
||||
post: async () => {
|
||||
if (shouldFail) {
|
||||
shouldFail = false;
|
||||
const error = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' });
|
||||
throw error;
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
client.sleep = async () => undefined;
|
||||
|
||||
const originalInfo = console.info;
|
||||
const messages: string[] = [];
|
||||
try {
|
||||
console.info = (...args: unknown[]) => {
|
||||
messages.push(args.map((value) => String(value)).join(' '));
|
||||
};
|
||||
|
||||
await (client as unknown as AnkiConnectClient).invoke('notesInfo', { notes: [1] });
|
||||
|
||||
assert.match(messages.join('\n'), /AnkiConnect notesInfo retry 1\/3 after 200ms delay/);
|
||||
} finally {
|
||||
console.info = originalInfo;
|
||||
}
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export class AnkiConnectClient {
|
||||
|
||||
constructor(url: string) {
|
||||
const httpAgent = new http.Agent({
|
||||
keepAlive: true,
|
||||
keepAlive: false,
|
||||
keepAliveMsecs: 1000,
|
||||
maxSockets: 5,
|
||||
maxFreeSockets: 2,
|
||||
@@ -51,7 +51,7 @@ export class AnkiConnectClient {
|
||||
});
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAlive: false,
|
||||
keepAliveMsecs: 1000,
|
||||
maxSockets: 5,
|
||||
maxFreeSockets: 2,
|
||||
@@ -106,7 +106,7 @@ export class AnkiConnectClient {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
const delay = Math.min(this.backoffMs * Math.pow(2, attempt - 1), this.maxBackoffMs);
|
||||
log.info(`AnkiConnect retry ${attempt}/${maxRetries} after ${delay}ms delay`);
|
||||
log.info(`AnkiConnect ${action} retry ${attempt}/${maxRetries} after ${delay}ms delay`);
|
||||
await this.sleep(delay);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,37 @@ import { KnownWordCacheManager } from './known-word-cache';
|
||||
|
||||
function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
||||
manager: KnownWordCacheManager;
|
||||
calls: {
|
||||
findNotes: number;
|
||||
notesInfo: number;
|
||||
};
|
||||
statePath: string;
|
||||
clientState: {
|
||||
findNotesResult: number[];
|
||||
notesInfoResult: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||
};
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-'));
|
||||
const statePath = path.join(stateDir, 'known-words-cache.json');
|
||||
const calls = {
|
||||
findNotes: 0,
|
||||
notesInfo: 0,
|
||||
};
|
||||
const clientState = {
|
||||
findNotesResult: [] as number[],
|
||||
notesInfoResult: [] as Array<{ noteId: number; fields: Record<string, { value: string }> }>,
|
||||
};
|
||||
const manager = new KnownWordCacheManager({
|
||||
client: {
|
||||
findNotes: async () => [],
|
||||
notesInfo: async () => [],
|
||||
findNotes: async () => {
|
||||
calls.findNotes += 1;
|
||||
return clientState.findNotesResult;
|
||||
},
|
||||
notesInfo: async (noteIds) => {
|
||||
calls.notesInfo += 1;
|
||||
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
|
||||
},
|
||||
},
|
||||
getConfig: () => config,
|
||||
knownWordCacheStatePath: statePath,
|
||||
@@ -25,12 +48,49 @@ function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
||||
|
||||
return {
|
||||
manager,
|
||||
calls,
|
||||
statePath,
|
||||
clientState,
|
||||
cleanup: () => {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('KnownWordCacheManager startLifecycle loads persisted cache without immediate rebuild', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: Date.now(),
|
||||
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":""}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
'1': ['猫'],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
manager.startLifecycle();
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
} finally {
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager invalidates persisted cache when fields.word changes', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
deck: 'Mining',
|
||||
@@ -69,6 +129,70 @@ test('KnownWordCacheManager invalidates persisted cache when fields.word changes
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: 1,
|
||||
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
|
||||
words: ['猫', '犬'],
|
||||
notes: {
|
||||
'1': ['猫'],
|
||||
'2': ['犬'],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
(
|
||||
manager as unknown as {
|
||||
loadKnownWordCacheState: () => void;
|
||||
}
|
||||
).loadKnownWordCacheState();
|
||||
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '鳥' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), false);
|
||||
assert.equal(manager.isKnownWord('犬'), false);
|
||||
assert.equal(manager.isKnownWord('鳥'), true);
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
version: number;
|
||||
words: string[];
|
||||
notes?: Record<string, string[]>;
|
||||
};
|
||||
assert.equal(persisted.version, 2);
|
||||
assert.deepEqual(persisted.words.sort(), ['鳥']);
|
||||
assert.deepEqual(persisted.notes, {
|
||||
'1': ['鳥'],
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager invalidates persisted cache when per-deck fields change', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
@@ -110,3 +234,27 @@ test('KnownWordCacheManager invalidates persisted cache when per-deck fields cha
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
addMinedWordsImmediately: false,
|
||||
},
|
||||
};
|
||||
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
manager.appendFromNoteInfo({
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Expression: { value: '猫' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), false);
|
||||
assert.equal(fs.existsSync(statePath), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,13 +64,23 @@ export interface KnownWordCacheNoteInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
interface KnownWordCacheState {
|
||||
interface KnownWordCacheStateV1 {
|
||||
readonly version: 1;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly words: string[];
|
||||
}
|
||||
|
||||
interface KnownWordCacheStateV2 {
|
||||
readonly version: 2;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly words: string[];
|
||||
readonly notes: Record<string, string[]>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
|
||||
|
||||
interface KnownWordCacheClient {
|
||||
findNotes: (
|
||||
query: string,
|
||||
@@ -92,7 +102,10 @@ export class KnownWordCacheManager {
|
||||
private knownWordsLastRefreshedAtMs = 0;
|
||||
private knownWordsStateKey = '';
|
||||
private knownWords: Set<string> = new Set();
|
||||
private wordReferenceCounts = new Map<string, number>();
|
||||
private noteWordsById = new Map<number, string[]>();
|
||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private isRefreshingKnownWords = false;
|
||||
private readonly statePath: string;
|
||||
|
||||
@@ -133,14 +146,14 @@ export class KnownWordCacheManager {
|
||||
);
|
||||
|
||||
this.loadKnownWordCacheState();
|
||||
void this.refreshKnownWords();
|
||||
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
|
||||
this.knownWordsRefreshTimer = setInterval(() => {
|
||||
void this.refreshKnownWords();
|
||||
}, refreshIntervalMs);
|
||||
this.scheduleKnownWordRefreshLifecycle();
|
||||
}
|
||||
|
||||
stopLifecycle(): void {
|
||||
if (this.knownWordsRefreshTimeout) {
|
||||
clearTimeout(this.knownWordsRefreshTimeout);
|
||||
this.knownWordsRefreshTimeout = null;
|
||||
}
|
||||
if (this.knownWordsRefreshTimer) {
|
||||
clearInterval(this.knownWordsRefreshTimer);
|
||||
this.knownWordsRefreshTimer = null;
|
||||
@@ -148,7 +161,7 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
if (!this.isKnownWordCacheEnabled() || !this.shouldAddMinedWordsImmediately()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,32 +173,26 @@ export class KnownWordCacheManager {
|
||||
this.knownWordsStateKey = currentStateKey;
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) {
|
||||
const normalized = this.normalizeKnownWordForLookup(rawWord);
|
||||
if (!normalized || this.knownWords.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
this.knownWords.add(normalized);
|
||||
addedCount += 1;
|
||||
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo);
|
||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (addedCount > 0) {
|
||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||
}
|
||||
this.persistKnownWordCacheState();
|
||||
log.info(
|
||||
'Known-word cache updated in-session',
|
||||
`added=${addedCount}`,
|
||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||
);
|
||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||
}
|
||||
this.persistKnownWordCacheState();
|
||||
log.info(
|
||||
'Known-word cache updated in-session',
|
||||
`noteId=${noteInfo.noteId}`,
|
||||
`wordCount=${nextWords.length}`,
|
||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||
);
|
||||
}
|
||||
|
||||
clearKnownWordCacheState(): void {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
try {
|
||||
if (fs.existsSync(this.statePath)) {
|
||||
@@ -218,33 +225,38 @@ export class KnownWordCacheManager {
|
||||
maxRetries: 0,
|
||||
})) as number[];
|
||||
|
||||
const nextKnownWords = new Set<string>();
|
||||
if (noteIds.length > 0) {
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < noteIds.length; i += chunkSize) {
|
||||
const chunk = noteIds.slice(i, i + chunkSize);
|
||||
const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[];
|
||||
const notesInfo = notesInfoResult as KnownWordCacheNoteInfo[];
|
||||
const currentNoteIds = Array.from(
|
||||
new Set(noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)),
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
for (const noteInfo of notesInfo) {
|
||||
for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) {
|
||||
const normalized = this.normalizeKnownWordForLookup(word);
|
||||
if (normalized) {
|
||||
nextKnownWords.add(normalized);
|
||||
}
|
||||
}
|
||||
if (this.noteWordsById.size === 0) {
|
||||
await this.rebuildFromCurrentNotes(currentNoteIds);
|
||||
} else {
|
||||
const currentNoteIdSet = new Set(currentNoteIds);
|
||||
for (const noteId of Array.from(this.noteWordsById.keys())) {
|
||||
if (!currentNoteIdSet.has(noteId)) {
|
||||
this.removeNoteSnapshot(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentNoteIds.length > 0) {
|
||||
const noteInfos = await this.fetchKnownWordNotesInfo(currentNoteIds);
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(
|
||||
noteInfo.noteId,
|
||||
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.knownWords = nextKnownWords;
|
||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
this.persistKnownWordCacheState();
|
||||
log.info(
|
||||
'Known-word cache refreshed',
|
||||
`noteCount=${noteIds.length}`,
|
||||
`wordCount=${nextKnownWords.size}`,
|
||||
`noteCount=${currentNoteIds.length}`,
|
||||
`wordCount=${this.knownWords.size}`,
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
||||
@@ -258,6 +270,10 @@ export class KnownWordCacheManager {
|
||||
return this.deps.getConfig().knownWords?.highlightEnabled === true;
|
||||
}
|
||||
|
||||
private shouldAddMinedWordsImmediately(): boolean {
|
||||
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
|
||||
}
|
||||
|
||||
private getKnownWordRefreshIntervalMs(): number {
|
||||
return getKnownWordCacheRefreshIntervalMinutes(this.deps.getConfig()) * 60_000;
|
||||
}
|
||||
@@ -322,64 +338,193 @@ export class KnownWordCacheManager {
|
||||
return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
|
||||
}
|
||||
|
||||
private scheduleKnownWordRefreshLifecycle(): void {
|
||||
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
|
||||
const scheduleInterval = () => {
|
||||
this.knownWordsRefreshTimer = setInterval(() => {
|
||||
void this.refreshKnownWords();
|
||||
}, refreshIntervalMs);
|
||||
};
|
||||
|
||||
const initialDelayMs = this.getMsUntilNextRefresh();
|
||||
this.knownWordsRefreshTimeout = setTimeout(() => {
|
||||
this.knownWordsRefreshTimeout = null;
|
||||
void this.refreshKnownWords();
|
||||
scheduleInterval();
|
||||
}, initialDelayMs);
|
||||
}
|
||||
|
||||
private getMsUntilNextRefresh(): number {
|
||||
if (this.knownWordsStateKey !== this.getKnownWordCacheStateKey()) {
|
||||
return 0;
|
||||
}
|
||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const remainingMs =
|
||||
this.getKnownWordRefreshIntervalMs() - (Date.now() - this.knownWordsLastRefreshedAtMs);
|
||||
return Math.max(0, remainingMs);
|
||||
}
|
||||
|
||||
private async rebuildFromCurrentNotes(noteIds: number[]): Promise<void> {
|
||||
this.clearInMemoryState();
|
||||
if (noteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteInfos = await this.fetchKnownWordNotesInfo(noteIds);
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(noteInfo.noteId, this.extractNormalizedKnownWordsFromNoteInfo(noteInfo));
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchKnownWordNotesInfo(noteIds: number[]): Promise<KnownWordCacheNoteInfo[]> {
|
||||
const noteInfos: KnownWordCacheNoteInfo[] = [];
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < noteIds.length; i += chunkSize) {
|
||||
const chunk = noteIds.slice(i, i + chunkSize);
|
||||
const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[];
|
||||
const chunkInfos = notesInfoResult as KnownWordCacheNoteInfo[];
|
||||
for (const noteInfo of chunkInfos) {
|
||||
if (!noteInfo || !Number.isInteger(noteInfo.noteId) || noteInfo.noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
noteInfos.push(noteInfo);
|
||||
}
|
||||
}
|
||||
return noteInfos;
|
||||
}
|
||||
|
||||
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
|
||||
const normalizedWords = normalizeKnownWordList(nextWords);
|
||||
const previousWords = this.noteWordsById.get(noteId) ?? [];
|
||||
if (knownWordListsEqual(previousWords, normalizedWords)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
if (normalizedWords.length > 0) {
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
} else {
|
||||
this.noteWordsById.delete(noteId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private removeNoteSnapshot(noteId: number): void {
|
||||
const previousWords = this.noteWordsById.get(noteId);
|
||||
if (!previousWords) {
|
||||
return;
|
||||
}
|
||||
this.noteWordsById.delete(noteId);
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
}
|
||||
|
||||
private addWordsToCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
this.knownWords.add(word);
|
||||
}
|
||||
}
|
||||
|
||||
private removeWordsFromCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
|
||||
if (nextCount > 0) {
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
} else {
|
||||
this.wordReferenceCounts.delete(word);
|
||||
this.knownWords.delete(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearInMemoryState(): void {
|
||||
this.knownWords = new Set();
|
||||
this.wordReferenceCounts = new Map();
|
||||
this.noteWordsById = new Map();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
}
|
||||
|
||||
private loadKnownWordCacheState(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.statePath)) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(this.statePath, 'utf-8');
|
||||
if (!raw.trim()) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!this.isKnownWordCacheStateValid(parsed)) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.scope !== this.getKnownWordCacheStateKey()) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKnownWords = new Set<string>();
|
||||
for (const value of parsed.words) {
|
||||
const normalized = this.normalizeKnownWordForLookup(value);
|
||||
if (normalized) {
|
||||
nextKnownWords.add(normalized);
|
||||
this.clearInMemoryState();
|
||||
if (parsed.version === 2) {
|
||||
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedWords = normalizeKnownWordList(words);
|
||||
if (normalizedWords.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
}
|
||||
} else {
|
||||
for (const value of parsed.words) {
|
||||
const normalized = this.normalizeKnownWordForLookup(value);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
this.knownWords.add(normalized);
|
||||
this.wordReferenceCounts.set(normalized, 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.knownWords = nextKnownWords;
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
} catch (error) {
|
||||
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
}
|
||||
}
|
||||
|
||||
private persistKnownWordCacheState(): void {
|
||||
try {
|
||||
const state: KnownWordCacheState = {
|
||||
version: 1,
|
||||
const notes: Record<string, string[]> = {};
|
||||
for (const [noteId, words] of this.noteWordsById.entries()) {
|
||||
if (words.length > 0) {
|
||||
notes[String(noteId)] = words;
|
||||
}
|
||||
}
|
||||
|
||||
const state: KnownWordCacheStateV2 = {
|
||||
version: 2,
|
||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||
scope: this.knownWordsStateKey,
|
||||
words: Array.from(this.knownWords),
|
||||
notes,
|
||||
};
|
||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||
} catch (error) {
|
||||
@@ -389,18 +534,35 @@ export class KnownWordCacheManager {
|
||||
|
||||
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const candidate = value as Partial<KnownWordCacheState>;
|
||||
if (candidate.version !== 1) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1 && candidate.version !== 2) return false;
|
||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||
if (typeof candidate.scope !== 'string') return false;
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry) => typeof entry === 'string')) {
|
||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.version === 2) {
|
||||
if (
|
||||
typeof candidate.notes !== 'object' ||
|
||||
candidate.notes === null ||
|
||||
Array.isArray(candidate.notes)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!Object.values(candidate.notes as Record<string, unknown>).every(
|
||||
(entry) =>
|
||||
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
|
||||
private extractNormalizedKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
|
||||
const words: string[] = [];
|
||||
const configuredFields = this.getConfiguredFields();
|
||||
for (const preferredField of configuredFields) {
|
||||
@@ -410,12 +572,12 @@ export class KnownWordCacheManager {
|
||||
const raw = noteInfo.fields[fieldName]?.value;
|
||||
if (!raw) continue;
|
||||
|
||||
const extracted = this.normalizeRawKnownWordValue(raw);
|
||||
if (extracted) {
|
||||
words.push(extracted);
|
||||
const normalized = this.normalizeKnownWordForLookup(raw);
|
||||
if (normalized) {
|
||||
words.push(normalized);
|
||||
}
|
||||
}
|
||||
return words;
|
||||
return normalizeKnownWordList(words);
|
||||
}
|
||||
|
||||
private normalizeRawKnownWordValue(value: string): string {
|
||||
@@ -430,6 +592,22 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKnownWordList(words: string[]): string[] {
|
||||
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
|
||||
}
|
||||
|
||||
function knownWordListsEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
|
||||
const exact = availableFieldNames.find((name) => name === preferredName);
|
||||
if (exact) return exact;
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
hasExplicitCommand,
|
||||
isHeadlessInitialCommand,
|
||||
parseArgs,
|
||||
shouldRunSettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
@@ -101,7 +102,8 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
const refreshKnownWords = parseArgs(['--refresh-known-words']);
|
||||
assert.equal(refreshKnownWords.help, false);
|
||||
assert.equal(hasExplicitCommand(refreshKnownWords), true);
|
||||
assert.equal(shouldStartApp(refreshKnownWords), false);
|
||||
assert.equal(shouldStartApp(refreshKnownWords), true);
|
||||
assert.equal(isHeadlessInitialCommand(refreshKnownWords), true);
|
||||
|
||||
const settings = parseArgs(['--settings']);
|
||||
assert.equal(settings.settings, true);
|
||||
|
||||
@@ -376,6 +376,10 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isHeadlessInitialCommand(args: CliArgs): boolean {
|
||||
return args.refreshKnownWords;
|
||||
}
|
||||
|
||||
export function shouldStartApp(args: CliArgs): boolean {
|
||||
if (args.stop && !args.start) return false;
|
||||
if (
|
||||
@@ -391,6 +395,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.mineSentence ||
|
||||
args.mineSentenceMultiple ||
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.refreshKnownWords ||
|
||||
args.toggleSecondarySub ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
|
||||
@@ -19,7 +19,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /default: 7777/);
|
||||
assert.match(output, /--launch-mpv/);
|
||||
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
||||
assert.match(output, /--refresh-known-words/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
|
||||
@@ -35,7 +35,6 @@ ${B}Mining${R}
|
||||
--trigger-field-grouping Run Kiku field grouping
|
||||
--trigger-subsync Run subtitle sync
|
||||
--toggle-secondary-sub Cycle secondary subtitle mode
|
||||
--refresh-known-words Refresh known words cache
|
||||
--open-runtime-options Open runtime options palette
|
||||
|
||||
${B}AniList${R}
|
||||
|
||||
@@ -1435,7 +1435,8 @@ test('validates ankiConnect knownWords behavior values', () => {
|
||||
"ankiConnect": {
|
||||
"knownWords": {
|
||||
"highlightEnabled": "yes",
|
||||
"refreshMinutes": -5
|
||||
"refreshMinutes": -5,
|
||||
"addMinedWordsImmediately": "no"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
@@ -1456,6 +1457,13 @@ test('validates ankiConnect knownWords behavior values', () => {
|
||||
);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.refreshMinutes'));
|
||||
assert.equal(
|
||||
config.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.addMinedWordsImmediately'),
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts valid ankiConnect knownWords behavior values', () => {
|
||||
@@ -1466,7 +1474,8 @@ test('accepts valid ankiConnect knownWords behavior values', () => {
|
||||
"ankiConnect": {
|
||||
"knownWords": {
|
||||
"highlightEnabled": true,
|
||||
"refreshMinutes": 120
|
||||
"refreshMinutes": 120,
|
||||
"addMinedWordsImmediately": false
|
||||
}
|
||||
}
|
||||
}`,
|
||||
@@ -1478,6 +1487,7 @@ test('accepts valid ankiConnect knownWords behavior values', () => {
|
||||
|
||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 120);
|
||||
assert.equal(config.ankiConnect.knownWords.addMinedWordsImmediately, false);
|
||||
});
|
||||
|
||||
test('validates ankiConnect n+1 minimum sentence word count', () => {
|
||||
|
||||
@@ -55,6 +55,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
knownWords: {
|
||||
highlightEnabled: false,
|
||||
refreshMinutes: 1440,
|
||||
addMinedWordsImmediately: true,
|
||||
matchMode: 'headword',
|
||||
decks: {},
|
||||
color: '#a6da95',
|
||||
|
||||
@@ -108,6 +108,12 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.refreshMinutes,
|
||||
description: 'Minutes between known-word cache refreshes.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||
description: 'Immediately append newly mined card words into the known-word cache.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.minSentenceWords',
|
||||
kind: 'number',
|
||||
|
||||
@@ -70,6 +70,20 @@ test('accepts knownWords.decks object format with field arrays', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
|
||||
const { context, warnings } = makeContext({
|
||||
knownWords: { addMinedWordsImmediately: false },
|
||||
});
|
||||
|
||||
applyAnkiConnectResolution(context);
|
||||
|
||||
assert.equal(context.resolved.ankiConnect.knownWords.addMinedWordsImmediately, false);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.addMinedWordsImmediately'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('converts legacy knownWords.decks array to object with default fields', () => {
|
||||
const { context, warnings } = makeContext({
|
||||
knownWords: { decks: ['Core Deck'] },
|
||||
|
||||
@@ -771,6 +771,24 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||
}
|
||||
|
||||
const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately);
|
||||
if (knownWordsAddMinedWordsImmediately !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
|
||||
knownWordsAddMinedWordsImmediately;
|
||||
} else if (knownWordsConfig.addMinedWordsImmediately !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
knownWordsConfig.addMinedWordsImmediately,
|
||||
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||
'Expected boolean.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||
} else {
|
||||
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||
}
|
||||
|
||||
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
||||
const hasValidNPlusOneMinSentenceWords =
|
||||
nPlusOneMinSentenceWords !== undefined &&
|
||||
|
||||
@@ -539,8 +539,21 @@ test('handleCliCommand runs refresh-known-words command', () => {
|
||||
assert.ok(calls.includes('refreshKnownWords'));
|
||||
});
|
||||
|
||||
test('handleCliCommand stops app after headless initial refresh-known-words completes', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
hasMainWindow: () => false,
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.ok(calls.includes('refreshKnownWords'));
|
||||
assert.ok(calls.includes('stopApp'));
|
||||
});
|
||||
|
||||
test('handleCliCommand reports async refresh-known-words errors to OSD', async () => {
|
||||
const { deps, calls, osd } = createDeps({
|
||||
hasMainWindow: () => false,
|
||||
refreshKnownWords: async () => {
|
||||
throw new Error('refresh boom');
|
||||
},
|
||||
@@ -551,4 +564,5 @@ test('handleCliCommand reports async refresh-known-words errors to OSD', async (
|
||||
|
||||
assert.ok(calls.some((value) => value.startsWith('error:refreshKnownWords failed:')));
|
||||
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
|
||||
assert.ok(calls.includes('stopApp'));
|
||||
});
|
||||
|
||||
@@ -334,12 +334,18 @@ export function handleCliCommand(
|
||||
'Update failed',
|
||||
);
|
||||
} else if (args.refreshKnownWords) {
|
||||
runAsyncWithOsd(
|
||||
() => deps.refreshKnownWords(),
|
||||
deps,
|
||||
'refreshKnownWords',
|
||||
'Refresh known words failed',
|
||||
);
|
||||
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||
deps
|
||||
.refreshKnownWords()
|
||||
.catch((err) => {
|
||||
deps.error('refreshKnownWords failed:', err);
|
||||
deps.showMpvOsd(`Refresh known words failed: ${(err as Error).message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
if (shouldStopAfterRun) {
|
||||
deps.stopApp();
|
||||
}
|
||||
});
|
||||
} else if (args.toggleSecondarySub) {
|
||||
deps.cycleSecondarySubMode();
|
||||
} else if (args.triggerFieldGrouping) {
|
||||
|
||||
@@ -109,6 +109,60 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled
|
||||
assert.equal(setIntegrationCalls, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
isVisibleOverlayVisible: () => false,
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => {},
|
||||
setWindowTracker: () => {},
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
createWindowTracker: () => null,
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: true } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: () => {
|
||||
createdIntegrations += 1;
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 7,
|
||||
deleteNoteId: 8,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
});
|
||||
|
||||
assert.equal(createdIntegrations, 1);
|
||||
assert.equal(startedIntegrations, 0);
|
||||
assert.equal(setIntegrationCalls, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime merges shared ai config with Anki overrides', () => {
|
||||
initializeOverlayRuntime({
|
||||
backendOverride: null,
|
||||
|
||||
@@ -75,6 +75,7 @@ export function initializeOverlayRuntime(options: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
}): void {
|
||||
options.createMainWindow();
|
||||
@@ -135,7 +136,9 @@ export function initializeOverlayRuntime(options: {
|
||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||
});
|
||||
integration.start();
|
||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||
integration.start();
|
||||
}
|
||||
options.setAnkiIntegration(integration);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,3 +93,104 @@ test('runAppReadyRuntime minimal startup skips Yomitan and first-run setup while
|
||||
|
||||
assert.deepEqual(calls, ['bootstrap', 'reload-config', 'handle-initial-args']);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI startup', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {
|
||||
calls.push('config-warning');
|
||||
},
|
||||
setLogLevel: () => {
|
||||
calls.push('set-log-level');
|
||||
},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {
|
||||
calls.push('set-secondary-sub-mode');
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {
|
||||
calls.push('subtitle-ws');
|
||||
},
|
||||
startAnnotationWebsocket: () => {
|
||||
calls.push('annotation-ws');
|
||||
},
|
||||
startTexthooker: () => {
|
||||
calls.push('texthooker');
|
||||
},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
calls.push('mecab');
|
||||
},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {
|
||||
calls.push('immersion');
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {
|
||||
calls.push('jellyfin');
|
||||
},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('first-run');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {
|
||||
calls.push('prewarm');
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {
|
||||
calls.push('visible-overlay');
|
||||
},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
runHeadlessInitialCommand: async () => {
|
||||
calls.push('run-headless-command');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldRunHeadlessInitialCommand: () => true,
|
||||
shouldUseMinimalStartup: () => false,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'bootstrap',
|
||||
'reload-config',
|
||||
'init-runtime-options',
|
||||
'run-headless-command',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -131,10 +131,12 @@ export interface AppReadyRuntimeDeps {
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
runHeadlessInitialCommand?: () => Promise<void>;
|
||||
handleInitialArgs: () => void;
|
||||
logDebug?: (message: string) => void;
|
||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
||||
now?: () => number;
|
||||
shouldRunHeadlessInitialCommand?: () => boolean;
|
||||
shouldUseMinimalStartup?: () => boolean;
|
||||
shouldSkipHeavyStartup?: () => boolean;
|
||||
}
|
||||
@@ -184,6 +186,20 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const startupStartedAtMs = now();
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldRunHeadlessInitialCommand?.()) {
|
||||
deps.reloadConfig();
|
||||
deps.initRuntimeOptionsManager();
|
||||
if (deps.runHeadlessInitialCommand) {
|
||||
await deps.runHeadlessInitialCommand();
|
||||
} else {
|
||||
deps.createMpvClient();
|
||||
deps.createSubtitleTimingTracker();
|
||||
deps.initializeOverlayRuntime();
|
||||
deps.handleInitialArgs();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (deps.shouldUseMinimalStartup?.()) {
|
||||
deps.reloadConfig();
|
||||
deps.handleInitialArgs();
|
||||
|
||||
@@ -68,7 +68,9 @@ function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
||||
version?: number;
|
||||
words?: string[];
|
||||
};
|
||||
if (raw.version === 1 && Array.isArray(raw.words)) return new Set(raw.words);
|
||||
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) {
|
||||
return new Set(raw.words);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
86
src/main.ts
86
src/main.ts
@@ -31,6 +31,7 @@ import {
|
||||
screen,
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { mergeAiConfig } from './ai/config';
|
||||
|
||||
function getPasswordStoreArg(argv: string[]): string | null {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
@@ -102,8 +103,10 @@ import { RuntimeOptionsManager } from './runtime-options';
|
||||
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
|
||||
import { createLogger, setLogLevel, type LogLevelSource } from './logger';
|
||||
import { resolveDefaultLogFilePath } from './logger';
|
||||
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||
import {
|
||||
commandNeedsOverlayRuntime,
|
||||
isHeadlessInitialCommand,
|
||||
parseArgs,
|
||||
shouldRunSettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
@@ -2837,6 +2840,50 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
|
||||
logError: (message, error) => logger.error(message, error),
|
||||
});
|
||||
|
||||
async function runHeadlessInitialCommand(): Promise<void> {
|
||||
if (!appState.initialArgs?.refreshKnownWords) {
|
||||
handleInitialArgs();
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedConfig = getResolvedConfig();
|
||||
if (resolvedConfig.ankiConnect.enabled !== true) {
|
||||
logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
|
||||
process.exitCode = 1;
|
||||
requestAppQuit();
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveAnkiConfig =
|
||||
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
|
||||
resolvedConfig.ankiConnect;
|
||||
const integration = new AnkiIntegration(
|
||||
effectiveAnkiConfig,
|
||||
new SubtitleTimingTracker(),
|
||||
{ send: () => undefined } as never,
|
||||
undefined,
|
||||
undefined,
|
||||
async () => ({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: false,
|
||||
cancelled: true,
|
||||
}),
|
||||
path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
mergeAiConfig(resolvedConfig.ai, resolvedConfig.ankiConnect?.ai),
|
||||
);
|
||||
|
||||
try {
|
||||
await integration.refreshKnownWordCache();
|
||||
} catch (error) {
|
||||
logger.error('Headless known-word refresh failed:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
integration.stop();
|
||||
requestAppQuit();
|
||||
}
|
||||
}
|
||||
|
||||
const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
reloadConfigMainDeps: {
|
||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||
@@ -2984,13 +3031,16 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
runHeadlessInitialCommand: () => runHeadlessInitialCommand(),
|
||||
handleInitialArgs: () => handleInitialArgs(),
|
||||
shouldRunHeadlessInitialCommand: () =>
|
||||
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
shouldUseMinimalStartup: () =>
|
||||
Boolean(
|
||||
appState.initialArgs?.stats &&
|
||||
(appState.initialArgs?.statsCleanup ||
|
||||
appState.initialArgs?.statsBackground ||
|
||||
appState.initialArgs?.statsStop),
|
||||
(appState.initialArgs?.statsCleanup ||
|
||||
appState.initialArgs?.statsBackground ||
|
||||
appState.initialArgs?.statsStop),
|
||||
),
|
||||
shouldSkipHeavyStartup: () =>
|
||||
Boolean(
|
||||
@@ -3096,6 +3146,7 @@ const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => appState.initialArgs,
|
||||
isBackgroundMode: () => appState.backgroundMode,
|
||||
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
|
||||
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
|
||||
ensureTray: () => ensureTray(),
|
||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||
hasImmersionTracker: () => Boolean(appState.immersionTracker),
|
||||
@@ -4139,8 +4190,24 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
},
|
||||
createMainWindow: () => createMainWindow(),
|
||||
registerGlobalShortcuts: () => registerGlobalShortcuts(),
|
||||
createMainWindow: () => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
createMainWindow();
|
||||
},
|
||||
registerGlobalShortcuts: () => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
registerGlobalShortcuts();
|
||||
},
|
||||
createWindowTracker: (override, targetMpvSocketPath) => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return null;
|
||||
}
|
||||
return createWindowTrackerCore(override, targetMpvSocketPath);
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
updateVisibleOverlayBounds(geometry),
|
||||
getOverlayWindows: () => getOverlayWindows(),
|
||||
@@ -4148,6 +4215,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
showDesktopNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
shouldStartAnkiIntegration: () =>
|
||||
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
@@ -4155,7 +4224,12 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
appState.overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
startBackgroundWarmups: () => startBackgroundWarmups(),
|
||||
startBackgroundWarmups: () => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
startBackgroundWarmups();
|
||||
},
|
||||
},
|
||||
});
|
||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||
|
||||
@@ -51,10 +51,12 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
||||
setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible'];
|
||||
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
||||
runHeadlessInitialCommand?: AppReadyRuntimeDeps['runHeadlessInitialCommand'];
|
||||
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
|
||||
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
|
||||
logDebug?: AppReadyRuntimeDeps['logDebug'];
|
||||
now?: AppReadyRuntimeDeps['now'];
|
||||
shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand'];
|
||||
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
|
||||
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
|
||||
}
|
||||
@@ -115,10 +117,12 @@ export function createAppReadyRuntimeDeps(
|
||||
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
setVisibleOverlayVisible: params.setVisibleOverlayVisible,
|
||||
initializeOverlayRuntime: params.initializeOverlayRuntime,
|
||||
runHeadlessInitialCommand: params.runHeadlessInitialCommand,
|
||||
handleInitialArgs: params.handleInitialArgs,
|
||||
onCriticalConfigErrors: params.onCriticalConfigErrors,
|
||||
logDebug: params.logDebug,
|
||||
now: params.now,
|
||||
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
|
||||
};
|
||||
|
||||
@@ -34,10 +34,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
runHeadlessInitialCommand: deps.runHeadlessInitialCommand,
|
||||
handleInitialArgs: deps.handleInitialArgs,
|
||||
onCriticalConfigErrors: deps.onCriticalConfigErrors,
|
||||
logDebug: deps.logDebug,
|
||||
now: deps.now,
|
||||
shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ test('initial args handler no-ops without initial args', () => {
|
||||
getInitialArgs: () => null,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
@@ -28,6 +29,7 @@ test('initial args handler ensures tray in background mode', () => {
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
@@ -49,6 +51,7 @@ test('initial args handler auto-connects mpv when needed', () => {
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -75,6 +78,7 @@ test('initial args handler forwards args to cli handler', () => {
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
@@ -95,6 +99,7 @@ test('initial args handler can ensure tray outside background mode when requeste
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => true,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
@@ -108,3 +113,31 @@ test('initial args handler can ensure tray outside background mode when requeste
|
||||
handleInitialArgs();
|
||||
assert.equal(ensuredTray, true);
|
||||
});
|
||||
|
||||
test('initial args handler skips tray and mpv auto-connect for headless refresh', () => {
|
||||
let ensuredTray = false;
|
||||
let connectCalls = 0;
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ refreshKnownWords: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => true,
|
||||
shouldRunHeadlessInitialCommand: () => true,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
getMpvClient: () => ({
|
||||
connected: false,
|
||||
connect: () => {
|
||||
connectCalls += 1;
|
||||
},
|
||||
}),
|
||||
logInfo: () => {},
|
||||
handleCliCommand: () => {},
|
||||
});
|
||||
|
||||
handleInitialArgs();
|
||||
assert.equal(ensuredTray, false);
|
||||
assert.equal(connectCalls, 0);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export function createHandleInitialArgsHandler(deps: {
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
isBackgroundMode: () => boolean;
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||
ensureTray: () => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
@@ -19,13 +20,15 @@ export function createHandleInitialArgsHandler(deps: {
|
||||
return (): void => {
|
||||
const initialArgs = deps.getInitialArgs();
|
||||
if (!initialArgs) return;
|
||||
const runHeadless = deps.shouldRunHeadlessInitialCommand(initialArgs);
|
||||
|
||||
if (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup()) {
|
||||
if (!runHeadless && (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup())) {
|
||||
deps.ensureTray();
|
||||
}
|
||||
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (
|
||||
!runHeadless &&
|
||||
!deps.isTexthookerOnlyMode() &&
|
||||
!initialArgs.stats &&
|
||||
deps.hasImmersionTracker() &&
|
||||
|
||||
@@ -10,6 +10,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
getInitialArgs: () => args,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => calls.push('ensure-tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -21,6 +22,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
assert.equal(deps.getInitialArgs(), args);
|
||||
assert.equal(deps.isBackgroundMode(), true);
|
||||
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
|
||||
assert.equal(deps.shouldRunHeadlessInitialCommand(args), false);
|
||||
assert.equal(deps.isTexthookerOnlyMode(), false);
|
||||
assert.equal(deps.hasImmersionTracker(), true);
|
||||
assert.equal(deps.getMpvClient(), mpvClient);
|
||||
|
||||
@@ -4,6 +4,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
isBackgroundMode: () => boolean;
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||
ensureTray: () => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
@@ -15,6 +16,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
getInitialArgs: () => deps.getInitialArgs(),
|
||||
isBackgroundMode: () => deps.isBackgroundMode(),
|
||||
shouldEnsureTrayOnStartup: () => deps.shouldEnsureTrayOnStartup(),
|
||||
shouldRunHeadlessInitialCommand: (args: CliArgs) => deps.shouldRunHeadlessInitialCommand(args),
|
||||
ensureTray: () => deps.ensureTray(),
|
||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||
hasImmersionTracker: () => deps.hasImmersionTracker(),
|
||||
|
||||
@@ -8,6 +8,7 @@ test('initial args runtime handler composes main deps and runs initial command f
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => calls.push('tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -35,6 +36,30 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () =>
|
||||
getInitialArgs: () => ({ stats: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => calls.push('tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
getMpvClient: () => ({
|
||||
connected: false,
|
||||
connect: () => calls.push('connect'),
|
||||
}),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
|
||||
});
|
||||
|
||||
handleInitialArgs();
|
||||
|
||||
assert.deepEqual(calls, ['cli:initial']);
|
||||
});
|
||||
|
||||
test('initial args runtime handler skips tray and mpv auto-connect for headless refresh', () => {
|
||||
const calls: string[] = [];
|
||||
const handleInitialArgs = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => ({ refreshKnownWords: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => true,
|
||||
shouldRunHeadlessInitialCommand: () => true,
|
||||
ensureTray: () => calls.push('tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
|
||||
@@ -43,6 +43,7 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
|
||||
cancelled: true,
|
||||
}) as KikuFieldGroupingChoice,
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => true,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
|
||||
@@ -30,6 +30,7 @@ type InitializeOverlayRuntimeCore = (options: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
}) => void;
|
||||
|
||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||
|
||||
@@ -39,6 +39,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
cancelled: true,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
});
|
||||
|
||||
const deps = build();
|
||||
@@ -46,6 +47,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
assert.equal(deps.isVisibleOverlayVisible(), true);
|
||||
assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock');
|
||||
assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
|
||||
assert.equal(deps.shouldStartAnkiIntegration(), false);
|
||||
|
||||
deps.createMainWindow();
|
||||
deps.registerGlobalShortcuts();
|
||||
|
||||
@@ -33,10 +33,12 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
height: number;
|
||||
}) => void;
|
||||
getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows'];
|
||||
createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker'];
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
}) {
|
||||
return (): OverlayRuntimeOptionsMainDeps => ({
|
||||
getBackendOverride: () => deps.appState.backendOverride,
|
||||
@@ -56,6 +58,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
setWindowTracker: (tracker) => {
|
||||
deps.appState.windowTracker = tracker;
|
||||
},
|
||||
createWindowTracker: deps.createWindowTracker,
|
||||
getResolvedConfig: () => deps.getResolvedConfig(),
|
||||
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
|
||||
getMpvClient: () => deps.appState.mpvClient,
|
||||
@@ -67,5 +70,6 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
shouldStartAnkiIntegration: () => true,
|
||||
});
|
||||
|
||||
const options = buildOptions();
|
||||
@@ -35,6 +36,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
assert.equal(options.isVisibleOverlayVisible(), true);
|
||||
assert.equal(options.getMpvSocketPath(), '/tmp/mpv.sock');
|
||||
assert.equal(options.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
|
||||
assert.equal(options.shouldStartAnkiIntegration(), true);
|
||||
options.createMainWindow();
|
||||
options.registerGlobalShortcuts();
|
||||
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
|
||||
@@ -17,6 +17,10 @@ type OverlayRuntimeOptions = {
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
createWindowTracker?: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||
@@ -30,6 +34,7 @@ type OverlayRuntimeOptions = {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
};
|
||||
|
||||
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
@@ -42,6 +47,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
createWindowTracker?: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||
@@ -55,6 +64,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
}) {
|
||||
return (): OverlayRuntimeOptions => ({
|
||||
backendOverride: deps.getBackendOverride(),
|
||||
@@ -66,6 +76,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
getOverlayWindows: deps.getOverlayWindows,
|
||||
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
||||
setWindowTracker: deps.setWindowTracker,
|
||||
createWindowTracker: deps.createWindowTracker,
|
||||
getResolvedConfig: deps.getResolvedConfig,
|
||||
getSubtitleTimingTracker: deps.getSubtitleTimingTracker,
|
||||
getMpvClient: deps.getMpvClient,
|
||||
@@ -75,5 +86,6 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ export interface AnkiConnectConfig {
|
||||
knownWords?: {
|
||||
highlightEnabled?: boolean;
|
||||
refreshMinutes?: number;
|
||||
addMinedWordsImmediately?: boolean;
|
||||
matchMode?: NPlusOneMatchMode;
|
||||
decks?: Record<string, string[]>;
|
||||
color?: string;
|
||||
@@ -754,6 +755,7 @@ export interface ResolvedConfig {
|
||||
knownWords: {
|
||||
highlightEnabled: boolean;
|
||||
refreshMinutes: number;
|
||||
addMinedWordsImmediately: boolean;
|
||||
matchMode: NPlusOneMatchMode;
|
||||
decks: Record<string, string[]>;
|
||||
color: string;
|
||||
|
||||
Reference in New Issue
Block a user