cards/apps/web/tests/anki-parse.test.ts
Till JS 2ca09fe0c3 Phase 8c: Anki-Import via portiertem Parser
Strategie-B-Ausnahme: parse.ts (Anki-Format-Parser via JSZip + sql.js)
und AnkiImport.svelte (UI-Stages) sind aus mana-monorepo portiert,
mit Source-Comment-Header dokumentiert. Anki-Format ist standalone
Parser-Logik, kein Architektur-Schmuggel.

Neuer server-authoritative import.ts schreibt direkt gegen die
cards-api ($lib/api/decks + cards) — keine Stores, keine Dexie.
Anki "::"-Hierarchie wird zu " / "-Strings flach. Fallback-Deck
"Anki-Import" für Karten ohne explizites Deck. Cloze-Karten kommen
first-class durch (Sub-Index pro Cluster, Sprint 8a/8b).

Phase-8-MVP-Scope: Bilder + Audio werden gedroppt (Option A) — der
sanitizeAnkiHtml entfernt <img> und [sound:…] ersatzlos. Späterer
Media-Pfad (lokaler Cards-Upload oder mana-media nach Phase 2) ist
additiv.

Neue Route /import + Top-Nav-Link. Hermetic Vitest (5 Cases): baut
zur Laufzeit ein Mini-.apkg via sql.js + JSZip und prüft den
Parser-Output (basic, basic-reverse, cloze, sanitize, dedupe auf
Note-Ebene). svelte-check 0 errors, prod-Build sauber.

sql-wasm.wasm liegt in static/ (660kB) — fix für sql.js 1.14.1, vom
Browser einmal geladen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:43:12 +02:00

169 lines
5.8 KiB
TypeScript

/**
* 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<Blob> {
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('strippt Bilder und Audio-Markup', () => {
const out = sanitizeAnkiHtml('Vorne <img src="paris.jpg"> Hinten [sound:audio.mp3] fertig.');
expect(out).toBe('Vorne Hinten fertig.');
});
it('konvertiert Bold/Italic zu Markdown', () => {
expect(sanitizeAnkiHtml('Das <b>ist</b> <i>wichtig</i>')).toBe('Das **ist** *wichtig*');
});
it('decodiert HTML-Entities', () => {
expect(sanitizeAnkiHtml('Q&amp;A &lt;tag&gt;')).toBe('Q&A <tag>');
});
it('lässt Cloze-Markup intakt', () => {
const out = sanitizeAnkiHtml('Die {{c1::Hauptstadt}} ist <b>Paris</b>.');
expect(out).toBe('Die {{c1::Hauptstadt}} ist **Paris**.');
});
});