From 9cd401cc48d1e78d5f29b56391312fb349dc6075 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 28 Feb 2026 01:23:58 -0800 Subject: [PATCH] fix(anki): harden proxy auto-enrichment and docs --- ...ransport-for-push-based-auto-enrichment.md | 1 + config.example.jsonc | 2 +- docs/anki-integration.md | 35 ++++- src/anki-integration.ts | 3 + .../anki-connect-proxy.test.ts | 122 ++++++++++++++++ src/anki-integration/anki-connect-proxy.ts | 137 +++++++++++++++++- src/anki-integration/note-update-workflow.ts | 20 ++- .../definitions/defaults-integrations.ts | 2 +- 8 files changed, 305 insertions(+), 17 deletions(-) diff --git a/backlog/tasks/task-71 - Anki-integration-add-local-AnkiConnect-proxy-transport-for-push-based-auto-enrichment.md b/backlog/tasks/task-71 - Anki-integration-add-local-AnkiConnect-proxy-transport-for-push-based-auto-enrichment.md index 92f580a..cd44f4c 100644 --- a/backlog/tasks/task-71 - Anki-integration-add-local-AnkiConnect-proxy-transport-for-push-based-auto-enrichment.md +++ b/backlog/tasks/task-71 - Anki-integration-add-local-AnkiConnect-proxy-transport-for-push-based-auto-enrichment.md @@ -27,6 +27,7 @@ Scope: Current unmerged working-tree changes implement an optional local AnkiCon Delivered behavior: - Added proxy server that forwards AnkiConnect requests and enqueues addNote/addNotes note IDs for post-create enrichment, with de-duplication and loop-configuration protection. +- Added follow-up response-shape compatibility handling so proxy enqueue works for both envelope (`{result,error}`) and bare JSON payloads, including `multi` variants. - Added config schema/defaults/resolution for ankiConnect.proxy (enabled, host, port, upstreamUrl) with validation warnings and fallback behavior. - Runtime now supports transport switching (polling vs proxy) and restarts transport when runtime config patches change transport keys. - Added Yomitan default-profile server sync helper to keep bundled parser profile aligned with configured Anki endpoint. diff --git a/config.example.jsonc b/config.example.jsonc index 31a3590..8244f6b 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -37,7 +37,7 @@ // ========================================== "logging": { "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error - }, // Controls logging verbosity. + }, // Controls logging verbosity. Keep this as an object; do not replace with a bare string. // ========================================== // Keyboard Shortcuts diff --git a/docs/anki-integration.md b/docs/anki-integration.md index 8d62e9e..327d09d 100644 --- a/docs/anki-integration.md +++ b/docs/anki-integration.md @@ -14,7 +14,7 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po SubMiner supports two auto-enrichment transport modes: -1. `proxy` (default): runs a local AnkiConnect-compatible proxy and enriches cards immediately after successful `addNote` / `addNotes` responses. +1. `proxy` (default): runs a local AnkiConnect-compatible proxy and enriches cards immediately after successful `addNote` / `addNotes` / `multi` responses. 2. `polling`: polls AnkiConnect at `ankiConnect.pollingRate` (default: 3s). In both modes, the enrichment workflow is the same: @@ -70,6 +70,39 @@ In Yomitan, go to Settings → Profile and: This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets `profiles[0]` when it is blank or still default. +### Proxy Troubleshooting (quick checks) + +If auto-enrichment appears to do nothing: + +1. Confirm proxy listener is running while SubMiner is active: + +```bash +ss -ltnp | rg 8766 +``` + +2. Confirm requests can pass through the proxy: + +```bash +curl -sS http://127.0.0.1:8766 \ + -H 'content-type: application/json' \ + -d '{"action":"version","version":2}' +``` + +3. Check both log sinks: + +- Launcher/mpv-integrated log: `~/.cache/SubMiner/mp.log` +- App runtime log: `~/.config/SubMiner/logs/SubMiner-YYYY-MM-DD.log` + +4. Ensure config JSONC is valid and logging shape is correct: + +```jsonc +"logging": { + "level": "debug" +} +``` + +`"logging": "debug"` is invalid for current schema and can break reload/start behavior. + ## Field Mapping SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`: diff --git a/src/anki-integration.ts b/src/anki-integration.ts index bb08cc2..6b46cdd 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -243,6 +243,9 @@ export class AnkiIntegration { return new AnkiConnectProxyServer({ shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false, processNewCard: (noteId: number) => this.processNewCard(noteId), + getDeck: () => this.config.deck, + findNotes: async (query, options) => + (await this.client.findNotes(query, options)) as number[], logInfo: (message, ...args) => log.info(message, ...args), logWarn: (message, ...args) => log.warn(message, ...args), logError: (message, ...args) => log.error(message, ...args), diff --git a/src/anki-integration/anki-connect-proxy.test.ts b/src/anki-integration/anki-connect-proxy.test.ts index a1dd8a2..35c84c6 100644 --- a/src/anki-integration/anki-connect-proxy.test.ts +++ b/src/anki-integration/anki-connect-proxy.test.ts @@ -38,6 +38,26 @@ test('proxy enqueues addNote result for enrichment', async () => { assert.deepEqual(processed, [42]); }); +test('proxy enqueues addNote bare numeric response for enrichment', async () => { + const processed: number[] = []; + const proxy = new AnkiConnectProxyServer({ + shouldAutoUpdateNewCards: () => true, + processNewCard: async (noteId) => { + processed.push(noteId); + }, + logInfo: () => undefined, + logWarn: () => undefined, + logError: () => undefined, + }); + + (proxy as unknown as { + maybeEnqueueFromRequest: (request: Record, responseBody: Buffer) => void; + }).maybeEnqueueFromRequest({ action: 'addNote' }, Buffer.from('42', 'utf8')); + + await waitForCondition(() => processed.length === 1); + assert.deepEqual(processed, [42]); +}); + test('proxy de-duplicates addNotes IDs within the same response', async () => { const processed: number[] = []; const proxy = new AnkiConnectProxyServer({ @@ -62,6 +82,108 @@ test('proxy de-duplicates addNotes IDs within the same response', async () => { assert.deepEqual(processed, [101, 102]); }); +test('proxy enqueues note IDs from multi action addNote/addNotes results', async () => { + const processed: number[] = []; + const proxy = new AnkiConnectProxyServer({ + shouldAutoUpdateNewCards: () => true, + processNewCard: async (noteId) => { + processed.push(noteId); + }, + logInfo: () => undefined, + logWarn: () => undefined, + logError: () => undefined, + }); + + (proxy as unknown as { + maybeEnqueueFromRequest: (request: Record, responseBody: Buffer) => void; + }).maybeEnqueueFromRequest( + { + action: 'multi', + params: { + actions: [ + { action: 'version' }, + { action: 'addNote' }, + { action: 'addNotes' }, + ], + }, + }, + Buffer.from(JSON.stringify({ result: [6, 777, [888, 777, null]], error: null }), 'utf8'), + ); + + await waitForCondition(() => processed.length === 2); + assert.deepEqual(processed, [777, 888]); +}); + +test('proxy enqueues note IDs from bare multi action results', async () => { + const processed: number[] = []; + const proxy = new AnkiConnectProxyServer({ + shouldAutoUpdateNewCards: () => true, + processNewCard: async (noteId) => { + processed.push(noteId); + }, + logInfo: () => undefined, + logWarn: () => undefined, + logError: () => undefined, + }); + + (proxy as unknown as { + maybeEnqueueFromRequest: (request: Record, responseBody: Buffer) => void; + }).maybeEnqueueFromRequest( + { + action: 'multi', + params: { + actions: [{ action: 'version' }, { action: 'addNote' }, { action: 'addNotes' }], + }, + }, + Buffer.from(JSON.stringify([6, 777, [888, null]]), 'utf8'), + ); + + await waitForCondition(() => processed.length === 2); + assert.deepEqual(processed, [777, 888]); +}); + +test('proxy enqueues note IDs from multi action envelope results', async () => { + const processed: number[] = []; + const proxy = new AnkiConnectProxyServer({ + shouldAutoUpdateNewCards: () => true, + processNewCard: async (noteId) => { + processed.push(noteId); + }, + logInfo: () => undefined, + logWarn: () => undefined, + logError: () => undefined, + }); + + (proxy as unknown as { + maybeEnqueueFromRequest: (request: Record, responseBody: Buffer) => void; + }).maybeEnqueueFromRequest( + { + action: 'multi', + params: { + actions: [ + { action: 'version' }, + { action: 'addNote' }, + { action: 'addNotes' }, + ], + }, + }, + Buffer.from( + JSON.stringify({ + result: [ + { result: 6, error: null }, + { result: 777, error: null }, + { result: [888, 777, null], error: null }, + ], + error: null, + }), + 'utf8', + ), + ); + + await waitForCondition(() => processed.length === 2); + assert.deepEqual(processed, [777, 888]); +}); + test('proxy skips auto-enrichment when auto-update is disabled', async () => { const processed: number[] = []; const proxy = new AnkiConnectProxyServer({ diff --git a/src/anki-integration/anki-connect-proxy.ts b/src/anki-integration/anki-connect-proxy.ts index 97175ef..df1f99b 100644 --- a/src/anki-integration/anki-connect-proxy.ts +++ b/src/anki-integration/anki-connect-proxy.ts @@ -15,6 +15,13 @@ interface AnkiConnectEnvelope { export interface AnkiConnectProxyServerDeps { shouldAutoUpdateNewCards: () => boolean; processNewCard: (noteId: number) => Promise; + getDeck?: () => string | undefined; + findNotes?: ( + query: string, + options?: { + maxRetries?: number; + }, + ) => Promise; logInfo: (message: string, ...args: unknown[]) => void; logWarn: (message: string, ...args: unknown[]) => void; logError: (message: string, ...args: unknown[]) => void; @@ -169,26 +176,118 @@ export class AnkiConnectProxyServer { const action = typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? ''); - if (action !== 'addNote' && action !== 'addNotes') { + if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') { return; } - const responseJson = this.tryParseJson(responseBody) as AnkiConnectEnvelope | null; - if (!responseJson || responseJson.error !== null) { + const parsedResponse = this.tryParseJsonValue(responseBody); + if (parsedResponse === null || parsedResponse === undefined) { + return; + } + + const responseResult = this.extractSuccessfulResult(parsedResponse); + if (responseResult === null) { return; } const noteIds = - action === 'addNote' - ? this.collectSingleResultId(responseJson.result) - : this.collectBatchResultIds(responseJson.result); + action === 'multi' + ? this.collectMultiResultIds(requestJson, responseResult) + : this.collectNoteIdsForAction(action, responseResult); if (noteIds.length === 0) { + void this.enqueueMostRecentAddedNote(); return; } this.enqueueNotes(noteIds); } + private async enqueueMostRecentAddedNote(): Promise { + const findNotes = this.deps.findNotes; + if (!findNotes) { + return; + } + + try { + const deck = this.deps.getDeck ? this.deps.getDeck() : undefined; + const query = deck ? `"deck:${deck}" added:1` : 'added:1'; + const noteIds = await findNotes(query, { maxRetries: 0 }); + if (!noteIds || noteIds.length === 0) { + return; + } + const latestNoteId = Math.max(...noteIds); + this.deps.logInfo( + `[anki-proxy] Falling back to latest added note ${latestNoteId} (response did not include note IDs)`, + ); + this.enqueueNotes([latestNoteId]); + } catch (error) { + this.deps.logWarn( + '[anki-proxy] Failed latest-note fallback lookup:', + (error as Error).message, + ); + } + } + + private collectNoteIdsForAction(action: string, result: unknown): number[] { + if (action === 'addNote') { + return this.collectSingleResultId(result); + } + if (action === 'addNotes') { + return this.collectBatchResultIds(result); + } + return []; + } + + private collectMultiResultIds(requestJson: Record, result: unknown): number[] { + if (!Array.isArray(result)) { + return []; + } + const params = + requestJson.params && typeof requestJson.params === 'object' + ? (requestJson.params as Record) + : null; + const actions = Array.isArray(params?.actions) ? params.actions : []; + if (actions.length === 0) { + return []; + } + + const noteIds: number[] = []; + const count = Math.min(actions.length, result.length); + for (let index = 0; index < count; index += 1) { + const actionEntry = actions[index]; + if (!actionEntry || typeof actionEntry !== 'object') { + continue; + } + const actionName = + typeof (actionEntry as Record).action === 'string' + ? ((actionEntry as Record).action as string) + : ''; + const actionResult = this.extractMultiActionResult(result[index]); + if (actionResult === null) { + continue; + } + noteIds.push(...this.collectNoteIdsForAction(actionName, actionResult)); + } + return noteIds; + } + + private extractMultiActionResult(value: unknown): unknown | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return value; + } + + const envelope = value as Record; + if (!Object.prototype.hasOwnProperty.call(envelope, 'result')) { + return value; + } + + if (envelope.error !== null && envelope.error !== undefined) { + return null; + } + + return envelope.result; + } + private collectSingleResultId(value: unknown): number[] { if (typeof value === 'number' && Number.isInteger(value) && value > 0) { return [value]; @@ -284,6 +383,32 @@ export class AnkiConnectProxyServer { } } + private tryParseJsonValue(rawBody: Buffer): unknown { + if (rawBody.length === 0) { + return null; + } + try { + return JSON.parse(rawBody.toString('utf8')); + } catch { + return null; + } + } + + private extractSuccessfulResult(value: unknown): unknown | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return value; + } + + const envelope = value as Partial; + if (!Object.prototype.hasOwnProperty.call(envelope, 'result')) { + return value; + } + if (envelope.error !== null && envelope.error !== undefined) { + return null; + } + return envelope.result; + } + private setCorsHeaders(res: ServerResponse): void { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index 2ffc761..0709dd6 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -91,10 +91,14 @@ export class NoteUpdateWorkflow { this.deps.appendKnownWordsFromNoteInfo(noteInfo); const fields = this.deps.extractFields(noteInfo.fields); - const expressionText = fields.expression || fields.word || ''; - if (!expressionText) { - this.deps.logWarn('No expression/word field found in card:', noteId); - return; + const expressionText = (fields.expression || fields.word || '').trim(); + const hasExpressionText = expressionText.length > 0; + if (!hasExpressionText) { + // Some note types omit Expression/Word; still run enrichment updates and skip duplicate checks. + this.deps.logWarn( + 'No expression/word field found in card; skipping duplicate checks but continuing update:', + noteId, + ); } const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); @@ -103,7 +107,7 @@ export class NoteUpdateWorkflow { sentenceCardConfig.kikuEnabled && sentenceCardConfig.kikuFieldGrouping !== 'disabled'; let duplicateNoteId: number | null = null; - if (shouldRunFieldGrouping) { + if (shouldRunFieldGrouping && hasExpressionText) { duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo); } @@ -195,11 +199,11 @@ export class NoteUpdateWorkflow { if (updatePerformed) { await this.deps.client.updateNoteFields(noteId, updatedFields); await this.deps.addConfiguredTagsToNote(noteId); - this.deps.logInfo('Updated card fields for:', expressionText); - await this.deps.showNotification(noteId, expressionText); + this.deps.logInfo('Updated card fields for:', hasExpressionText ? expressionText : noteId); + await this.deps.showNotification(noteId, hasExpressionText ? expressionText : noteId); } - if (shouldRunFieldGrouping && duplicateNoteId !== null) { + if (shouldRunFieldGrouping && hasExpressionText && duplicateNoteId !== null) { let noteInfoForGrouping = noteInfo; if (updatePerformed) { const refreshedInfoResult = await this.deps.client.notesInfo([noteId]); diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index c49bec7..fd34bc1 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -9,7 +9,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< url: 'http://127.0.0.1:8765', pollingRate: 3000, proxy: { - enabled: false, + enabled: true, host: '127.0.0.1', port: 8766, upstreamUrl: 'http://127.0.0.1:8765',