fix(stats): fallback to configured field when note info missing; honor A

- use configured audio field directly when note info is absent instead of null
- use configured AnkiConnect URL in notesInfo route instead of hardcoded default
- use shouldForceOverrideYomitanAnkiServer instead of forceOverride: true in addYomitanNote
- add test for audio field fallback and Yomitan override policy
This commit is contained in:
2026-06-06 02:40:57 -07:00
parent 2fca5bbdba
commit 01c2d9eb1d
4 changed files with 110 additions and 23 deletions
@@ -354,6 +354,7 @@ type CapturedAnkiRequest = {
async function withFakeAnkiConnect<T>( async function withFakeAnkiConnect<T>(
fn: (requests: CapturedAnkiRequest[], url: string) => Promise<T>, fn: (requests: CapturedAnkiRequest[], url: string) => Promise<T>,
options?: { notesInfoFields?: Record<string, { value: string }> | null },
): Promise<T> { ): Promise<T> {
const requests: CapturedAnkiRequest[] = []; const requests: CapturedAnkiRequest[] = [];
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
@@ -373,18 +374,21 @@ async function withFakeAnkiConnect<T>(
} else if (payload.action === 'notesInfo') { } else if (payload.action === 'notesInfo') {
const noteIds = payload.params?.notes ?? []; const noteIds = payload.params?.notes ?? [];
body = { body = {
result: noteIds.map((noteId) => ({ result:
noteId, options?.notesInfoFields === null
fields: { ? []
Expression: { value: '猫' }, : noteIds.map((noteId) => ({
ExpressionAudio: { value: '[sound:word.mp3]' }, noteId,
Sentence: { value: '' }, fields: options?.notesInfoFields ?? {
SentenceAudio: { value: '' }, Expression: { value: '' },
Picture: { value: '' }, ExpressionAudio: { value: '[sound:word.mp3]' },
MiscInfo: { value: '' }, Sentence: { value: '' },
SelectionText: { value: '' }, SentenceAudio: { value: '' },
}, Picture: { value: '' },
})), MiscInfo: { value: '' },
SelectionText: { value: '' },
},
})),
error: null, error: null,
}; };
} else if (payload.action === 'findCards') { } else if (payload.action === 'findCards') {
@@ -1771,6 +1775,65 @@ describe('stats server API routes', () => {
}); });
}); });
it('POST /api/stats/mine-card writes audio cards to configured audio field when note info is missing', 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(), {
createMediaGenerator: () => ({
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
url,
deck: 'Mining',
fields: {
audio: 'Voice',
image: 'Picture',
sentence: 'Sentence',
},
media: {
generateAudio: true,
generateImage: false,
},
isLapis: {
enabled: true,
sentenceCardModel: 'Lapis Morph',
},
},
});
const res = await app.request('/api/stats/mine-card?mode=audio', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
assert.match(
updateRequest?.params?.note?.fields?.Voice ?? '',
/^\[sound:subminer_audio_\d+\.mp3\]$/,
);
},
{ notesInfoFields: null },
);
});
});
it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => { it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => {
await withTempDir(async (dir) => { await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv'); const sourcePath = path.join(dir, 'episode.mkv');
@@ -1989,9 +2052,12 @@ describe('stats server API routes', () => {
it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => { it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const requests: unknown[] = []; const requests: Array<{ input: string; body: unknown }> = [];
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push(init?.body ? JSON.parse(String(init.body)) : null); requests.push({
input: String(input),
body: init?.body ? JSON.parse(String(init.body)) : null,
});
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
result: [ result: [
@@ -2012,6 +2078,7 @@ describe('stats server API routes', () => {
try { try {
const app = createStatsApp(createMockTracker(), { const app = createStatsApp(createMockTracker(), {
ankiConnectConfig: { url: 'http://127.0.0.1:9876' },
resolveAnkiNoteId: (noteId) => (noteId === 111 ? 222 : noteId), resolveAnkiNoteId: (noteId) => (noteId === 111 ? 222 : noteId),
}); });
const res = await app.request('/api/stats/anki/notesInfo', { const res = await app.request('/api/stats/anki/notesInfo', {
@@ -2023,9 +2090,12 @@ describe('stats server API routes', () => {
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.deepEqual(requests, [ assert.deepEqual(requests, [
{ {
action: 'notesInfo', input: 'http://127.0.0.1:9876',
version: 6, body: {
params: { notes: [222] }, action: 'notesInfo',
version: 6,
params: { notes: [222] },
},
}, },
]); ]);
assert.deepEqual(await res.json(), [ assert.deepEqual(await res.json(), [
+3 -2
View File
@@ -184,7 +184,7 @@ function getStatsDirectMiningAudioFieldNames(
: 'SentenceAudio'; : 'SentenceAudio';
const expressionAudioField = noteInfo const expressionAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, configuredAudioField) ? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
: null; : configuredAudioField;
if (mode === 'sentence') { if (mode === 'sentence') {
return uniqueFieldNames(sentenceAudioField); return uniqueFieldNames(sentenceAudioField);
@@ -1045,7 +1045,8 @@ export function createStatsApp(
), ),
); );
try { try {
const response = await fetch('http://127.0.0.1:8765', { const ankiConfig = getAnkiConnectConfig();
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
+4 -3
View File
@@ -4476,10 +4476,11 @@ const startLocalStatsServer = (): void => {
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
resolveSentenceSearchHeadwords, resolveSentenceSearchHeadwords,
addYomitanNote: async (word: string) => { addYomitanNote: async (word: string) => {
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; const ankiConnectConfig = getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: true, forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: getResolvedConfig().ankiConnect.deck, deck: ankiConnectConfig.deck,
}); });
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) { if (result.noteId && result.duplicateNoteIds.length > 0) {
+15
View File
@@ -237,6 +237,21 @@ test('warm tokenization release reuses current subtitle payload instead of synth
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/); assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
}); });
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
const source = readMainSource();
const startStatsServerBlock = source.match(
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
)?.groups?.body;
const addYomitanNoteBlock = startStatsServerBlock?.match(
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(addYomitanNoteBlock);
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
});
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => { test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(