From 0174014b3597f5ecd63d05fa77c37b93fc2eb8e7 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 4 Jun 2026 23:13:55 -0700 Subject: [PATCH] fix(stats): fall back to Default deck when deck config is blank - Trim deck name before checking falsy so empty string uses Default - Add fake AnkiConnect server helper and regression test for blank deck --- changes/fix-stats-vocab-example-mining.md | 4 + release/release-notes.md | 30 ----- .../services/__tests__/stats-server.test.ts | 114 ++++++++++++++++++ src/core/services/stats-server.ts | 2 +- 4 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 changes/fix-stats-vocab-example-mining.md delete mode 100644 release/release-notes.md diff --git a/changes/fix-stats-vocab-example-mining.md b/changes/fix-stats-vocab-example-mining.md new file mode 100644 index 00000000..846490b7 --- /dev/null +++ b/changes/fix-stats-vocab-example-mining.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Fixed vocab-page example sentence mining buttons failing when the Anki deck setting is blank or Yomitan card formats are ordered with a non-term card first. diff --git a/release/release-notes.md b/release/release-notes.md deleted file mode 100644 index 7baa1e38..00000000 --- a/release/release-notes.md +++ /dev/null @@ -1,30 +0,0 @@ -## Highlights -### Changed - -- **Yomitan**: Updated the bundled Yomitan to the latest revision. - - Picks up the newest lookup improvements and fixes from the SubMiner Yomitan fork. - -### Fixed - -- **Anki / Animated AVIF**: Clips with word audio no longer start or end early. - - Clip boundaries are now snapped to the nearest AVIF frame edge, keeping audio lead-in and playback in sync. - -- **macOS Overlay**: Resolved several interactivity and focus issues triggered by autoplay and modal windows. - - After autoplay starts with "wait for overlay to be ready" enabled, subtitles are immediately hoverable and Yomitan lookups work - no longer require an extra click to activate. - - After any modal closes (Settings, Stats, sidebar, etc.), the overlay and subtitles reappear automatically and mpv keyboard shortcuts (pause, seek, etc.) are restored to mpv right away, including in native fullscreen. - -- **Hyprland Fullscreen Overlay**: Fixed overlay alignment when mpv is fullscreen on Hyprland. - - Compositor client bounds are now verified before positioning, so the stats panel, modals, and subtitle sidebar no longer shift below the mpv window. - -## Installation - -See the README and docs/installation guide for full setup steps. - -## Assets - -- Linux: `SubMiner.AppImage` -- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip` -- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip` -- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher - -Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index b7aa3656..b94349bd 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -4,6 +4,7 @@ import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; +import type { AddressInfo } from 'node:net'; import { createStatsApp, startStatsServer } from '../stats-server.js'; import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; @@ -334,6 +335,57 @@ function withTempDir(fn: (dir: string) => Promise | T): Promise | T { return result; } +type CapturedAnkiRequest = { + action?: string; + params?: { + note?: { + deckName?: string; + modelName?: string; + fields?: Record; + tags?: string[]; + }; + }; +}; + +async function withFakeAnkiConnect( + fn: (requests: CapturedAnkiRequest[], url: string) => Promise, +): Promise { + const requests: CapturedAnkiRequest[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as CapturedAnkiRequest; + requests.push(payload); + + let body: unknown = { result: null, error: null }; + if (payload.action === 'addNote') { + if (!payload.params?.note?.deckName) { + body = { result: null, error: 'deck was not found: ' }; + } else { + body = { result: 12345, error: null }; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + try { + const address = server.address() as AddressInfo; + return await fn(requests, `http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } +} + describe('stats server API routes', () => { it('GET /api/stats/overview returns overview data', async () => { const app = createStatsApp(createMockTracker()); @@ -1035,6 +1087,68 @@ describe('stats server API routes', () => { assert.ok(Array.isArray(body)); }); + it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + + await withFakeAnkiConnect(async (requests, url) => { + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: { + url, + deck: '', + tags: ['SubMiner'], + fields: { + word: 'Expression', + audio: 'ExpressionAudio', + image: 'Picture', + sentence: 'Sentence', + miscInfo: 'MiscInfo', + translation: 'SelectionText', + }, + media: { + generateAudio: false, + generateImage: false, + }, + isLapis: { + enabled: true, + sentenceCardModel: 'Lapis Morph', + }, + isKiku: { + enabled: true, + fieldGrouping: 'manual', + deleteDuplicateInAuto: true, + }, + }, + }); + + const res = await app.request('/api/stats/mine-card?mode=sentence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 1_000, + endMs: 2_000, + sentence: '猫を見た', + word: '猫', + secondaryText: 'I saw a cat', + videoTitle: 'Episode 1', + }), + }); + + const body = await res.json(); + assert.equal(res.status, 200, JSON.stringify(body)); + assert.equal(body.noteId, 12345); + + const addNoteRequest = requests.find((request) => request.action === 'addNote'); + assert.equal(addNoteRequest?.params?.note?.deckName, 'Default'); + assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph'); + assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, 'を見た'); + assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x'); + }); + }); + }); + it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/episode/1/detail'); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 78fab283..c8fd0509 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -1101,7 +1101,7 @@ export function createStatsApp( } const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic'; - const deck = ankiConfig.deck ?? 'Default'; + const deck = ankiConfig.deck?.trim() || 'Default'; const tags = ankiConfig.tags ?? ['SubMiner']; try {