Some checks are pending
CI / validate (push) Waiting to run
Ω-1: Text-Only-Architektur ist scharfgestellt. Code-Cleanup: - 4 Components gelöscht: ImageOcclusionEditor, ImageOcclusionView, AudioFrontView, AudioUploadField - 3 API-Module gelöscht: routes/media.ts, services/storage.ts, db/schema/media.ts (mediaRefs + mediaFiles), routes/decks-from-image.ts - packages/cards-domain: image-occlusion.ts + Tests entfernt, CardTypeSchema reduziert auf basic/basic-reverse/cloze/typing/multiple-choice - 3 Web-Routes (study/[deckId], cards/new, cards/[id]/edit) bereinigt: Image-Occlusion- und Audio-Front-Code-Pfade raus - anki/import.ts text-only: kein Media-Upload mehr, img/sound werden ersatzlos gestrippt - 21 weitere Files bereinigt: dto, health, me, dsgvo, tools, cards, decks, share-handlers, marketplace/decks, marketplace/fork, marketplace/pull-requests, AnkiImport.svelte DB-Migrationen (noch nicht gerannt, idempotent): - 0004_wordeck_text_only.sql: DELETE image-occlusion/audio (0 betroffene Rows), media_files-Tabelle DROP, media_refs-Spalte DROP, CHECK cards.type IN (basic, basic-reverse, cloze, type-in, multiple-choice) - 0005_wordeck_license_rename.sql: Cardecky-Personal-Use-1.0 → Wordeck-Personal-Use-1.0, Cardecky-Pro-Only-1.0 → Wordeck-Pro-Only-1.0, Default + CHECK + Backfill Infrastruktur: - docker-compose.production.yml: cards-minio-Service raus, MinIO-Envs aus cards-api raus, CARDS_PUBLIC_URL + PUBLIC_CARDS_API_URL auf wordeck.com / api.wordeck.com - App-Manifest schon vorher auf wordeck umgestellt Type-Check grün (api, domain, web — alle 3 Sub-Pakete). 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('droppt Bilder und Audio ersatzlos (text-only Wordeck)', () => {
|
|
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**.');
|
|
});
|
|
});
|