/** * Hermetic Anki-Parser-Test. Wir bauen zur Laufzeit eine minimale * .apkg (sql.js + JSZip), füttern sie in `parseApkg` und prüfen das * ParsedAnki-Output. Keine Binär-Fixture im Repo — die Tests sind * vollständig reproducible aus reiner JS-Logik. * * Anki-Mindest-Schema: `col` (collection), `notes`, `cards` plus * JSON-encoded `models` und `decks` im einzigen `col`-Row. */ import { describe, it, expect } from 'vitest'; import JSZip from 'jszip'; import initSqlJs from 'sql.js'; import { readFileSync } from 'node:fs'; import { parseApkg, sanitizeAnkiHtml } from '../src/lib/anki/parse.ts'; // In Node-Tests muss locateFile auf den lokalen WASM-Pfad zeigen. // Wir patchen das einmal pro Test-Run — die Library cached SQL global. const WASM_BUFFER = readFileSync( new URL('../node_modules/sql.js/dist/sql-wasm.wasm', import.meta.url) ); async function buildSampleApkg(): Promise { const SQL = await initSqlJs({ wasmBinary: WASM_BUFFER as unknown as ArrayBuffer }); const db = new SQL.Database(); // Anki erwartet diese Tabellen auch wenn nur ein Subset benutzt wird. db.run(` CREATE TABLE col ( id INTEGER PRIMARY KEY, crt INTEGER, mod INTEGER, scm INTEGER, ver INTEGER, dty INTEGER, usn INTEGER, ls INTEGER, conf TEXT, models TEXT, decks TEXT, dconf TEXT, tags TEXT ); CREATE TABLE notes ( id INTEGER PRIMARY KEY, guid TEXT, mid INTEGER, mod INTEGER, usn INTEGER, tags TEXT, flds TEXT, sfld TEXT, csum INTEGER, flags INTEGER, data TEXT ); CREATE TABLE cards ( id INTEGER PRIMARY KEY, nid INTEGER, did INTEGER, ord INTEGER, mod INTEGER, usn INTEGER, type INTEGER, queue INTEGER, due INTEGER, ivl INTEGER, factor INTEGER, reps INTEGER, lapses INTEGER, left INTEGER, odue INTEGER, odid INTEGER, flags INTEGER, data TEXT ); `); const models = { // model-id "100" = Standard (1 template = basic) '100': { id: 100, name: 'Basic', type: 0, flds: [{ name: 'Front' }, { name: 'Back' }], tmpls: [{ name: 'Card 1' }], }, // model-id "101" = Standard mit 2 templates = basic-reverse '101': { id: 101, name: 'Basic + Reverse', type: 0, flds: [{ name: 'Front' }, { name: 'Back' }], tmpls: [{ name: 'Card 1' }, { name: 'Card 2' }], }, // model-id "102" = Cloze '102': { id: 102, name: 'Cloze', type: 1, flds: [{ name: 'Text' }, { name: 'Extra' }], tmpls: [{ name: 'Cloze' }], }, }; const decks = { '1': { id: 1, name: 'Default' }, '200': { id: 200, name: 'Geographie::Europa' }, '201': { id: 201, name: 'Vokabeln' }, }; db.run( `INSERT INTO col VALUES (1,0,0,0,0,0,0,0,'{}',?,?,'{}','{}')`, [JSON.stringify(models), JSON.stringify(decks)] ); // Drei Notes (basic, basic-reverse, cloze). const FS = '\x1f'; // Anki-Field-Separator db.run( `INSERT INTO notes VALUES (?,?,?,0,0,'',?,'',0,0,'')`, [1001, 'g1', 100, `Was ist 2+2?${FS}4`] ); db.run( `INSERT INTO notes VALUES (?,?,?,0,0,'',?,'',0,0,'')`, [1002, 'g2', 101, `Hauptstadt Frankreich${FS}Paris`] ); db.run( `INSERT INTO notes VALUES (?,?,?,0,0,'',?,'',0,0,'')`, [1003, 'g3', 102, `Die {{c1::Hauptstadt}} von {{c2::Frankreich}}.${FS}Geo-Quiz`] ); // Cards: 1 für basic (ord=0), 2 für basic-reverse (ord=0,1), // 2 für cloze (ord=0,1 — Anki erzeugt eine Card pro Cluster). db.run(`INSERT INTO cards VALUES (?,?,?,0,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9001, 1001, 200]); db.run(`INSERT INTO cards VALUES (?,?,?,0,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9002, 1002, 201]); db.run(`INSERT INTO cards VALUES (?,?,?,1,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9003, 1002, 201]); db.run(`INSERT INTO cards VALUES (?,?,?,0,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9004, 1003, 200]); db.run(`INSERT INTO cards VALUES (?,?,?,1,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9005, 1003, 200]); const sqliteBytes = db.export(); db.close(); const zip = new JSZip(); zip.file('collection.anki21', sqliteBytes); // Empty media manifest — kein extra File. zip.file('media', '{}'); const blob = await zip.generateAsync({ type: 'arraybuffer' }); return new Blob([blob]); } describe('parseApkg', () => { it('extrahiert Decks, Cards (basic, basic-reverse, cloze) und de-dupes Note-Rows', async () => { const apkg = await buildSampleApkg(); const result = await parseApkg(apkg); // Default-Deck (id=1) wird gefiltert. expect(result.decks).toHaveLength(2); expect(result.decks.map((d) => d.name).sort()).toEqual([ 'Geographie::Europa', 'Vokabeln', ]); // 3 Notes → 3 Karten (NICHT 5, weil basic-reverse + cloze auf Note-Ebene dedupliziert werden). expect(result.cards).toHaveLength(3); const types = result.cards.map((c) => c.type).sort(); expect(types).toEqual(['basic', 'basic-reverse', 'cloze']); const cloze = result.cards.find((c) => c.type === 'cloze'); expect(cloze?.fields.text).toBe('Die {{c1::Hauptstadt}} von {{c2::Frankreich}}.'); expect(cloze?.fields.extra).toBe('Geo-Quiz'); const basic = result.cards.find((c) => c.type === 'basic'); expect(basic?.fields).toEqual({ front: 'Was ist 2+2?', back: '4' }); const reverse = result.cards.find((c) => c.type === 'basic-reverse'); expect(reverse?.fields).toEqual({ front: 'Hauptstadt Frankreich', back: 'Paris' }); expect(result.skipped).toBe(0); }); }); describe('sanitizeAnkiHtml', () => { it('droppt Bilder und Audio ohne URL-Map (lossy fallback)', () => { const out = sanitizeAnkiHtml('Vorne Hinten [sound:audio.mp3] fertig.'); expect(out).toBe('Vorne Hinten fertig.'); }); it('ersetzt Bilder durch Markdown wenn URL-Map gesetzt', () => { const map = new Map([['paris.jpg', '/api/v1/media/abc']]); const out = sanitizeAnkiHtml('Vorne hinten.', map); expect(out).toBe('Vorne ![paris.jpg](/api/v1/media/abc) hinten.'); }); it('ersetzt [sound:…] durch