diff --git a/image-import.js b/image-import.js index 37414ad..95bf302 100644 --- a/image-import.js +++ b/image-import.js @@ -116,6 +116,65 @@ class ImageImporter { : ''; return typeof content === 'string' ? content : JSON.stringify(content); } + + /** + * Strip markdown fences, brace-slice, JSON.parse, schema-validate. + * Invalid entries are dropped with console warnings. + * @param {string} rawContent + * @returns {{ month: number|null, year: number|null, entries: Array<{name:string,date:string,share:number}>, notes: string[] }} + */ + parseResponse(rawContent) { + if (typeof rawContent !== 'string') { + throw new SyntaxError('Antwort ist kein String'); + } + + let text = rawContent.trim(); + + // Strip ```json ... ``` or ``` ... ``` fences + text = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, ''); + + // Brace-slice: find first { and last } + const firstBrace = text.indexOf('{'); + const lastBrace = text.lastIndexOf('}'); + if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) { + throw new SyntaxError('Kein JSON-Objekt in der Antwort gefunden'); + } + text = text.slice(firstBrace, lastBrace + 1); + + const parsed = JSON.parse(text); // may throw SyntaxError + + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { + throw new Error('Schema-Fehler: entries fehlt oder ist kein Array'); + } + + const validEntries = []; + for (const entry of parsed.entries) { + if (!entry || typeof entry.name !== 'string' || entry.name.trim().length === 0) { + console.warn('parseResponse: Eintrag mit leerem Namen verworfen', entry); + continue; + } + if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date)) { + console.warn('parseResponse: Eintrag mit ungueltigem Datum verworfen', entry); + continue; + } + const d = new Date(entry.date + 'T12:00:00'); + if (isNaN(d.getTime())) { + console.warn('parseResponse: Datum nicht parsebar', entry); + continue; + } + if (entry.share !== 0.5 && entry.share !== 1.0) { + console.warn('parseResponse: Eintrag mit ungueltigem share verworfen', entry); + continue; + } + validEntries.push({ name: entry.name.trim(), date: entry.date, share: entry.share }); + } + + const month = (typeof parsed.month === 'number' && parsed.month >= 1 && parsed.month <= 12) ? parsed.month : null; + const year = (typeof parsed.year === 'number' && parsed.year >= 2000) ? parsed.year : null; + const notes = Array.isArray(parsed.notes) ? parsed.notes.filter(n => typeof n === 'string') : []; + + return { month, year, entries: validEntries, notes }; + } } // Verbatim system prompt — German with Umlaute (per spec §7.3). diff --git a/test-suite.js b/test-suite.js index 30409b2..90230bf 100644 --- a/test-suite.js +++ b/test-suite.js @@ -646,6 +646,76 @@ runner.test('CallVisionAPI: 503 wirft mit Status', async (t) => { }); }); +// ============================================================================ +// ImageImporter Tests - parseResponse (Feature A) +// ============================================================================ + +runner.test('Parse: cleanes JSON wird geparst', (t) => { + const importer = new ImageImporter(null); + const raw = '{"month":11,"year":2025,"entries":[{"name":"Max","date":"2025-11-22","share":1.0}],"notes":[]}'; + const result = importer.parseResponse(raw); + t.assertEqual(result.entries.length, 1, '1 Eintrag'); + t.assertEqual(result.entries[0].name, 'Max', 'Name korrekt'); + t.assertEqual(result.month, 11, 'Monat korrekt'); +}); + +runner.test('Parse: JSON in Markdown-Fence wird gestrippt', (t) => { + const importer = new ImageImporter(null); + const raw = '```json\n{"entries":[{"name":"A","date":"2025-11-22","share":0.5}],"notes":[]}\n```'; + const result = importer.parseResponse(raw); + t.assertEqual(result.entries.length, 1, 'Fence wurde entfernt'); +}); + +runner.test('Parse: JSON mit Vortext wird per Brace-Slicing extrahiert', (t) => { + const importer = new ImageImporter(null); + const raw = 'Hier das Ergebnis:\n{"entries":[{"name":"A","date":"2025-11-22","share":1.0}],"notes":[]}'; + const result = importer.parseResponse(raw); + t.assertEqual(result.entries.length, 1, 'Vortext ignoriert'); +}); + +runner.test('Parse: Malformed JSON wirft SyntaxError', (t) => { + const importer = new ImageImporter(null); + try { + importer.parseResponse('das ist kein JSON'); + t.assertTrue(false, 'Sollte werfen'); + } catch (e) { + t.assertTrue(e instanceof SyntaxError || e.name === 'SyntaxError' || /JSON|Parse/i.test(e.message), 'SyntaxError erwartet'); + } +}); + +runner.test('Parse: fehlendes entries-Feld wirft', (t) => { + const importer = new ImageImporter(null); + try { + importer.parseResponse('{"month":11,"year":2025,"notes":[]}'); + t.assertTrue(false, 'Sollte werfen'); + } catch (e) { + t.assertTrue(/entries/i.test(e.message), 'Fehlermeldung erwaehnt entries'); + } +}); + +runner.test('Parse: share=0.75 verwirft den Eintrag', (t) => { + const importer = new ImageImporter(null); + const raw = '{"entries":[{"name":"A","date":"2025-11-22","share":0.75},{"name":"B","date":"2025-11-23","share":1.0}],"notes":[]}'; + const result = importer.parseResponse(raw); + t.assertEqual(result.entries.length, 1, 'Nur gueltiger Eintrag bleibt'); + t.assertEqual(result.entries[0].name, 'B', 'B uebrig'); +}); + +runner.test('Parse: invalides Datum verwirft den Eintrag', (t) => { + const importer = new ImageImporter(null); + const raw = '{"entries":[{"name":"A","date":"31.11.2025","share":1.0},{"name":"B","date":"2025-11-22","share":1.0}],"notes":[]}'; + const result = importer.parseResponse(raw); + t.assertEqual(result.entries.length, 1, 'Nur ISO-Datum bleibt'); + t.assertEqual(result.entries[0].name, 'B', 'B uebrig'); +}); + +runner.test('Parse: leerer Name wird verworfen', (t) => { + const importer = new ImageImporter(null); + const raw = '{"entries":[{"name":" ","date":"2025-11-22","share":1.0},{"name":"B","date":"2025-11-22","share":1.0}],"notes":[]}'; + const result = importer.parseResponse(raw); + t.assertEqual(result.entries.length, 1, 'Nur gueltiger Name bleibt'); +}); + // ============================================================================ // Display Functions // ============================================================================