Eigener cards-minio-Container im docker-compose (9100/9101 — Plattform
auf 9000/9001 bleibt isoliert). cardsadmin/cardsadmin als Dev-Default,
prod via env-Vars (CARDS_S3_*).
apps/api/src/services/storage.ts — schmaler StorageService um den
minio-Client. ensureBucket() ist idempotent (auto-create beim ersten
Upload). removeObjectsByPrefix() implementiert den DSGVO-Bucket-Sweep,
weil die S3-API kein Cascade kennt.
Neue Tabelle media_files in pgSchema('cards'):
id, user_id, object_key, mime_type, original_filename, size_bytes,
kind, created_at — kein FK auf cards (ein File kann mehreren Karten
gehören). objectKey-Format <userId>/<ulid>.<ext> für Bucket-Prefix-
Sweep beim DSGVO-Delete. Legacy mediaRefs bleibt als Slot.
Neuer Router /api/v1/media:
POST /upload — multipart, 25 MiB Default-Limit, image/audio/video
only (415 sonst), schreibt media_files-Row + speichert
in MinIO unter <userId>/<ulid>.<ext>
GET /:id — streamt aus MinIO mit Cache-Control: private,
immutable. Cross-User → 404 (nicht 403, anti-enumeration).
GET / — listet alle eigenen Files
DSGVO-Pfade (Service-Key + /me/delete) räumen jetzt auch media_files
+ MinIO-Bucket-Prefix mit ab. Storage-Sweep ist non-fatal — DB ist erst
konsistent gelöscht, dead bytes wären die schlimmstmögliche Folge.
Anki-Import: parse.ts sanitizeAnkiHtml akzeptiert wieder eine
Filename→URL-Map (war in Phase 8c gedroppt). import.ts lädt vor den
Karten alle referenzierten Media-Files via uploadMedia() in MinIO,
sammelt URLs, ersetzt Anki-Filenames durch /api/v1/media/<id>-Pfade
in `<img>` (Markdown) und `[sound:…]` (HTML <audio>). 4-fache Worker-
Concurrency.
apps/web/src/lib/markdown.ts: DOMPurify lässt jetzt <audio>/<video>/
<source> mit src/controls/preload-Attributen durch — sonst würden die
Audio-Tags aus dem Anki-Import gestrippt.
i18n-Strings (DE/EN) auf Media-Stage erweitert: stage_media,
done_media, what_works_media, dropzone_hint, preview_media.
import.what_skipped_media wird zur Bestätigung dass Media seit
Sprint 9k mit übernommen wird.
Manueller E2E-Smoke gegen lokale MinIO (cards-minio :9100):
- 1×1-PNG hochgeladen → 201 mit ID + URL
- /api/v1/media/<id> streamt 200 image/png 69 bytes (file-Identifikation
bestätigt)
- Cross-User → 404, ohne X-User-Id → 401, text/plain → 415
53 API-Tests grün (+4 neue media-Auth-Gate-Tests), 7 Web-Tests,
51 Domain-Tests, type-check + svelte-check 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
6.4 KiB
TypeScript
181 lines
6.4 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 ohne URL-Map (lossy fallback)', () => {
|
|
const out = sanitizeAnkiHtml('Vorne <img src="paris.jpg"> 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 <img src="paris.jpg"> hinten.', map);
|
|
expect(out).toBe('Vorne  hinten.');
|
|
});
|
|
|
|
it('ersetzt [sound:…] durch <audio> wenn URL-Map gesetzt', () => {
|
|
const map = new Map([['x.mp3', '/api/v1/media/xyz']]);
|
|
const out = sanitizeAnkiHtml('Vorne [sound:x.mp3] hinten.', map);
|
|
expect(out).toContain('<audio controls preload="metadata" src="/api/v1/media/xyz">');
|
|
});
|
|
|
|
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**.');
|
|
});
|
|
});
|