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>
169 lines
5.8 KiB
TypeScript
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&A <tag>')).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**.');
|
|
});
|
|
});
|