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
This commit is contained in:
2026-06-04 23:13:55 -07:00
parent 077aa89eb8
commit 0174014b35
4 changed files with 119 additions and 31 deletions
@@ -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.
-30
View File
@@ -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`.
@@ -4,6 +4,7 @@ import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import type { AddressInfo } from 'node:net';
import { createStatsApp, startStatsServer } from '../stats-server.js'; import { createStatsApp, startStatsServer } from '../stats-server.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
@@ -334,6 +335,57 @@ function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> | T {
return result; return result;
} }
type CapturedAnkiRequest = {
action?: string;
params?: {
note?: {
deckName?: string;
modelName?: string;
fields?: Record<string, string>;
tags?: string[];
};
};
};
async function withFakeAnkiConnect<T>(
fn: (requests: CapturedAnkiRequest[], url: string) => Promise<T>,
): Promise<T> {
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<void>((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<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
}
describe('stats server API routes', () => { describe('stats server API routes', () => {
it('GET /api/stats/overview returns overview data', async () => { it('GET /api/stats/overview returns overview data', async () => {
const app = createStatsApp(createMockTracker()); const app = createStatsApp(createMockTracker());
@@ -1035,6 +1087,68 @@ describe('stats server API routes', () => {
assert.ok(Array.isArray(body)); 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, '<b>猫</b>を見た');
assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x');
});
});
});
it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => { it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => {
const app = createStatsApp(createMockTracker()); const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/episode/1/detail'); const res = await app.request('/api/stats/episode/1/detail');
+1 -1
View File
@@ -1101,7 +1101,7 @@ export function createStatsApp(
} }
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic'; const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
const deck = ankiConfig.deck ?? 'Default'; const deck = ankiConfig.deck?.trim() || 'Default';
const tags = ankiConfig.tags ?? ['SubMiner']; const tags = ankiConfig.tags ?? ['SubMiner'];
try { try {