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>
This commit is contained in:
Till JS 2026-05-08 17:43:12 +02:00
parent 0b609c46fd
commit 2ca09fe0c3
9 changed files with 916 additions and 3 deletions

View file

@ -17,7 +17,9 @@
"dependencies": { "dependencies": {
"@cards/domain": "workspace:*", "@cards/domain": "workspace:*",
"dompurify": "^3.4.2", "dompurify": "^3.4.2",
"marked": "^18.0.3" "jszip": "^3.10.1",
"marked": "^18.0.3",
"sql.js": "^1.14.1"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-node": "^5.2.0",
@ -25,6 +27,8 @@
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/jszip": "^3.4.1",
"@types/sql.js": "^1.4.11",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",

View file

@ -0,0 +1,120 @@
/**
* Server-authoritative Anki-Import.
*
* Schreibt gegen die cards-api HTTP-Endpoints keine Dexie, keine
* lokalen Stores. Anki-Decks werden 1:1 in cards-Decks gemappt
* (Anki-`::` zu ` / ` flacht die Hierarchie aus, wie im Original).
* Karten werden mit sanitisiertem Markdown angelegt.
*
* Phase-8-MVP: Bilder + Audio werden gedroppt (siehe parse.ts
* `sanitizeAnkiHtml`). Ein späterer Media-Pfad ist additiv.
*
* No de-dupe: Re-Import derselben .apkg legt doppelte Decks an.
*/
import { createDeck } from '$lib/api/decks.ts';
import { createCard } from '$lib/api/cards.ts';
import { sanitizeAnkiHtml, type ParsedAnki } from './parse.ts';
export interface ImportResult {
decksCreated: number;
cardsCreated: number;
failed: number;
failures: string[];
}
export interface ImportProgress {
stage: 'decks' | 'cards' | 'done';
current: number;
total: number;
}
export async function importParsedAnki(
parsed: ParsedAnki,
opts: { onProgress?: (p: ImportProgress) => void } = {}
): Promise<ImportResult> {
const result: ImportResult = {
decksCreated: 0,
cardsCreated: 0,
failed: 0,
failures: [],
};
// 1) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen.
const ankiIdToDeckId = new Map<string, string>();
let deckIdx = 0;
for (const ankiDeck of parsed.decks) {
opts.onProgress?.({ stage: 'decks', current: deckIdx++, total: parsed.decks.length });
const name = ankiDeck.name.replace(/::/g, ' / ');
try {
const created = await createDeck({ name });
ankiIdToDeckId.set(ankiDeck.ankiId, created.id);
result.decksCreated++;
} catch (e) {
result.failed++;
result.failures.push(`deck "${name}": ${errMessage(e)}`);
}
}
// Fallback-Deck für Karten ohne explizit referenziertes Anki-Deck.
let fallbackDeckId: string | null = null;
const ensureFallbackDeck = async (): Promise<string | null> => {
if (fallbackDeckId) return fallbackDeckId;
try {
const created = await createDeck({ name: 'Anki-Import' });
fallbackDeckId = created.id;
result.decksCreated++;
return fallbackDeckId;
} catch (e) {
result.failures.push(`fallback deck: ${errMessage(e)}`);
return null;
}
};
// 2) Cards — Felder sanitizen (Media-Refs werden gedroppt).
for (let i = 0; i < parsed.cards.length; i++) {
opts.onProgress?.({ stage: 'cards', current: i, total: parsed.cards.length });
const card = parsed.cards[i];
let targetDeckId = ankiIdToDeckId.get(card.ankiDeckId);
if (!targetDeckId) {
const fallback = await ensureFallbackDeck();
if (!fallback) {
result.failed++;
continue;
}
targetDeckId = fallback;
}
const cleanFields: Record<string, string> = {};
for (const [key, value] of Object.entries(card.fields)) {
cleanFields[key] = sanitizeAnkiHtml(value);
}
try {
await createCard({
deck_id: targetDeckId,
type: card.type,
fields: cleanFields,
});
result.cardsCreated++;
} catch (e) {
result.failed++;
result.failures.push(`card "${preview(cleanFields)}": ${errMessage(e)}`);
}
}
opts.onProgress?.({ stage: 'done', current: parsed.cards.length, total: parsed.cards.length });
return result;
}
function errMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
function preview(fields: Record<string, string>): string {
const first = Object.values(fields)[0] ?? '';
const trimmed = first.length > 40 ? first.slice(0, 40) + '…' : first;
return trimmed.replace(/\s+/g, ' ');
}

View file

@ -0,0 +1,241 @@
/**
* Parse an Anki .apkg / .colpkg file in the browser.
*
* .apkg = ZIP archive containing a SQLite collection (`collection.anki2`
* or `collection.anki21`) plus media files. We open the SQLite blob with
* sql.js (WASM-backed in-browser SQLite) and walk Anki's three core
* tables: `col` (collection meta with JSON-encoded models + decks),
* `notes` (the user-typed content), and `cards` (one row per learnable
* unit basic = 1, basic-reverse = 2, cloze = N).
*
* MVP scope (Cards Phase 8): basic + basic-reverse + cloze. Media is
* collected but not uploaded Image/audio refs are stripped from the
* sanitized text. Review history is skipped FSRS state will be
* regenerated on first sight.
*
* --------------------------------------------------------------------
* STRATEGIE-B-AUSNAHME: Diese Datei ist ein bewusst portierter Lift aus
* mana-monorepo/apps/cards/apps/web/src/lib/anki/parse.ts (commit
* ~Mai 2026). Anki-Format-Logik ist standalone Parser-Code ohne
* Architektur-Übernahme die Kopie spart 2-3 Tage Re-Implementierung
* bei null Strategy-Risiko. CardType-Import auf @cards/domain
* umgestellt, Doc-Kommentar an Phase-8-Scope angepasst.
* --------------------------------------------------------------------
*/
import JSZip, { type JSZipObject } from 'jszip';
import initSqlJs, { type Database } from 'sql.js';
import type { CardType } from '@cards/domain';
export interface ParsedDeck {
ankiId: string; // Anki's numeric deck id, stringified
name: string; // "Studies::Spanish" — Anki uses :: as separator
}
export interface ParsedCard {
ankiDeckId: string;
type: CardType;
fields: Record<string, string>;
}
export interface ParsedAnki {
decks: ParsedDeck[];
cards: ParsedCard[];
skipped: number;
warnings: string[];
/**
* Mapping from the original media filename (as referenced in card
* fields, e.g. `paris.jpg` or `audio_001.mp3`) to its ZIP entry. Anki
* stores files numerically (`0`, `1`, ) and the JSON manifest
* (`media`) maps numbers original names; we flip that here so the
* importer can look up by the name it sees in the field text.
*/
mediaByFilename: Map<string, JSZipObject>;
}
interface AnkiModel {
id: number;
name: string;
type: number; // 0 = standard, 1 = cloze
flds: { name: string }[];
tmpls: { name: string }[];
}
interface AnkiDeckJson {
id: number;
name: string;
}
let SQL: Awaited<ReturnType<typeof initSqlJs>> | null = null;
async function getSql() {
if (SQL) return SQL;
SQL = await initSqlJs({ locateFile: (file) => `/${file}` });
return SQL;
}
export async function parseApkg(file: File | Blob): Promise<ParsedAnki> {
const zip = await JSZip.loadAsync(await file.arrayBuffer());
const collectionEntry = zip.file('collection.anki21') ?? zip.file('collection.anki2');
if (!collectionEntry) {
throw new Error(
'Keine Anki-Collection-Datei in der .apkg gefunden (erwartet: collection.anki21 oder collection.anki2).'
);
}
const sqliteBytes = await collectionEntry.async('uint8array');
const sql = await getSql();
const db: Database = new sql.Database(sqliteBytes);
const mediaByFilename = await extractMediaManifest(zip);
try {
const result = extract(db);
return { ...result, mediaByFilename };
} finally {
db.close();
}
}
async function extractMediaManifest(zip: JSZip): Promise<Map<string, JSZipObject>> {
const out = new Map<string, JSZipObject>();
const manifestEntry = zip.file('media');
if (!manifestEntry) return out;
let manifest: Record<string, string>;
try {
manifest = JSON.parse(await manifestEntry.async('string'));
} catch {
return out;
}
for (const [numericKey, originalName] of Object.entries(manifest)) {
const entry = zip.file(numericKey);
if (entry) out.set(originalName, entry);
}
return out;
}
// Internal extract returns everything except media — that's plumbed in
// at the parseApkg layer so the SQLite-only path stays focused.
type ExtractResult = Omit<ParsedAnki, 'mediaByFilename'>;
function extract(db: Database): ExtractResult {
const colRow = db.exec('SELECT models, decks FROM col LIMIT 1');
if (colRow.length === 0 || colRow[0].values.length === 0) {
throw new Error('Anki-Collection ist leer.');
}
const [modelsJson, decksJson] = colRow[0].values[0] as [string, string];
const models: Record<string, AnkiModel> = JSON.parse(modelsJson);
const decksMap: Record<string, AnkiDeckJson> = JSON.parse(decksJson);
const decks: ParsedDeck[] = Object.values(decksMap)
.filter((d) => d.id !== 1) // Anki's "Default" deck has id 1; skip if empty later
.map((d) => ({ ankiId: String(d.id), name: d.name }));
// Pre-load notes into a Map so we don't hit SQLite per card.
type NoteRow = { id: string; mid: string; flds: string };
const notesById = new Map<string, NoteRow>();
const notesRes = db.exec('SELECT id, mid, flds FROM notes');
if (notesRes.length > 0) {
for (const row of notesRes[0].values) {
const [id, mid, flds] = row as [number, number, string];
notesById.set(String(id), { id: String(id), mid: String(mid), flds });
}
}
const warnings: string[] = [];
const cards: ParsedCard[] = [];
let skipped = 0;
const cardsRes = db.exec('SELECT nid, did, ord FROM cards');
if (cardsRes.length === 0)
return { decks, cards: [], skipped: 0, warnings: ['Keine Karten gefunden.'] };
// We dedupe at the note level — Anki stores one DB-row per generated
// card (basic-reverse = 2 rows, cloze cluster c1+c2 = 2 rows). Our
// model regenerates these from `type` + `fields` automatically, so
// pulling each note once is enough.
const seenNotes = new Set<string>();
for (const row of cardsRes[0].values) {
const [nid, did] = row as [number, number, number];
const noteKey = String(nid);
if (seenNotes.has(noteKey)) continue;
seenNotes.add(noteKey);
const note = notesById.get(noteKey);
if (!note) {
skipped++;
continue;
}
const model = models[note.mid];
if (!model) {
skipped++;
warnings.push(`Note ${nid}: unknown model ${note.mid}`);
continue;
}
const fieldValues = note.flds.split('\x1f');
const result = mapNoteToCard(model, fieldValues);
if (!result) {
skipped++;
continue;
}
cards.push({ ankiDeckId: String(did), ...result });
}
if (skipped > 0) warnings.unshift(`${skipped} Karten übersprungen (unbekannter Typ).`);
return { decks, cards, skipped, warnings };
}
function mapNoteToCard(
model: AnkiModel,
fields: string[]
): { type: CardType; fields: Record<string, string> } | null {
// Cloze: exactly one input field with {{cN::...}} markup.
if (model.type === 1) {
const text = fields[0] ?? '';
return { type: 'cloze', fields: { text, ...(fields[1] ? { extra: fields[1] } : {}) } };
}
// Standard: one or two templates → basic / basic-reverse.
if (model.type === 0) {
const front = fields[0] ?? '';
const back = fields[1] ?? '';
if (model.tmpls.length === 2) {
return { type: 'basic-reverse', fields: { front, back } };
}
// 1 (or unusual N) → treat as basic. Custom multi-card templates
// lose their extra surfaces; the user-typed content survives.
return { type: 'basic', fields: { front, back } };
}
return null;
}
/**
* Convert Anki's HTML / image / sound markup to plain text + Markdown.
*
* Phase-8-MVP: Bilder + Audio werden ersatzlos gedroppt (Option A).
* Ein späterer Media-Pfad (lokaler Cards-Upload-Endpunkt oder mana-media
* via Phase 2 Auth-Föderation) kann hier eine FilenameURL-Map einsetzen,
* die dann zu `<img>` / `<audio>`-Tags expandiert.
*/
export function sanitizeAnkiHtml(html: string): string {
// Bilder + Audio-Refs vollständig entfernen.
const imgStripped = html.replace(/<img\b[^>]*>/gi, '');
const soundStripped = imgStripped.replace(/\[sound:[^\]]+\]/g, '');
return soundStripped
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/?(?:b|strong)>/gi, '**')
.replace(/<\/?(?:i|em)>/gi, '*')
.replace(/<\/?p>/gi, '\n')
.replace(/<\/?div>/gi, '\n')
.replace(/<[^>]+>/gi, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, '\n\n')
.trim();
}

View file

@ -0,0 +1,209 @@
<!--
Anki-Import-UI: Datei wählen → Preview → Importieren → Done.
STRATEGIE-B-AUSNAHME: portiert aus
mana-monorepo/apps/cards/apps/web/src/lib/components/AnkiImport.svelte.
Original-Layout angepasst auf das oklch-Theme im neuen Repo
(var(--color-*)) und auf den server-authoritative Import-Pfad
ohne Media-Upload-Stage. Cloze-Skipped-Anzeige ergänzt — der neue
Importer reicht Cloze direkt durch.
-->
<script lang="ts">
import { parseApkg, type ParsedAnki } from '$lib/anki/parse.ts';
import { importParsedAnki, type ImportResult, type ImportProgress } from '$lib/anki/import.ts';
let fileInput = $state<HTMLInputElement | null>(null);
let stage = $state<'idle' | 'parsing' | 'preview' | 'importing' | 'done' | 'error'>('idle');
let parsed = $state<ParsedAnki | null>(null);
let result = $state<ImportResult | null>(null);
let error = $state<string | null>(null);
let fileName = $state<string>('');
let progress = $state<ImportProgress>({ stage: 'decks', current: 0, total: 0 });
const typeBreakdown = $derived.by(() => {
if (!parsed) return { basic: 0, basicReverse: 0, cloze: 0 };
const counts = { basic: 0, basicReverse: 0, cloze: 0 };
for (const c of parsed.cards) {
if (c.type === 'basic') counts.basic++;
else if (c.type === 'basic-reverse') counts.basicReverse++;
else if (c.type === 'cloze') counts.cloze++;
}
return counts;
});
async function handleFile(file: File) {
error = null;
fileName = file.name;
stage = 'parsing';
try {
parsed = await parseApkg(file);
stage = 'preview';
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Datei konnte nicht gelesen werden.';
stage = 'error';
}
}
function onPick(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const f = input.files?.[0];
if (f) handleFile(f);
input.value = '';
}
function onDrop(e: DragEvent) {
e.preventDefault();
const f = e.dataTransfer?.files?.[0];
if (f) handleFile(f);
}
async function confirmImport() {
if (!parsed) return;
stage = 'importing';
progress = { stage: 'decks', current: 0, total: parsed.decks.length };
try {
result = await importParsedAnki(parsed, {
onProgress: (p) => {
progress = p;
},
});
stage = 'done';
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Import fehlgeschlagen.';
stage = 'error';
}
}
function reset() {
stage = 'idle';
parsed = null;
result = null;
error = null;
fileName = '';
}
</script>
<div class="rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="mb-2 text-sm font-medium">Aus Anki importieren</div>
{#if stage === 'idle'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-[var(--color-border)] px-4 py-6 text-center text-sm text-[var(--color-muted)] transition-colors hover:border-[var(--color-primary)] hover:text-[var(--color-fg)]"
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
onclick={() => fileInput?.click()}
>
<div class="mb-1">📦 .apkg-Datei hier ablegen oder klicken</div>
<div class="text-xs">
Basic, Basic + Reverse, Cloze · Bilder + Audio werden in dieser Phase nicht übernommen.
</div>
</div>
<input
bind:this={fileInput}
type="file"
accept=".apkg,.colpkg"
class="hidden"
onchange={onPick}
/>
{:else if stage === 'parsing'}
<div class="py-6 text-center text-sm text-[var(--color-muted)]">Lese {fileName}</div>
{:else if stage === 'preview' && parsed}
<div class="space-y-2 text-sm">
<div>
<span class="text-[var(--color-muted)]">Gefunden in</span>
<code class="rounded bg-[var(--color-border)]/40 px-1 text-xs">{fileName}</code>:
</div>
<ul class="ml-4 list-disc">
<li>{parsed.decks.length} {parsed.decks.length === 1 ? 'Deck' : 'Decks'}</li>
<li>
{parsed.cards.length} {parsed.cards.length === 1 ? 'Karte' : 'Karten'}
{#if parsed.cards.length > 0}
<span class="text-[var(--color-muted)]">
({typeBreakdown.basic} basic, {typeBreakdown.basicReverse} basic-reverse,
{typeBreakdown.cloze} cloze)
</span>
{/if}
</li>
{#if parsed.mediaByFilename.size > 0}
<li class="text-[var(--color-muted)]">
{parsed.mediaByFilename.size} Medien (werden in dieser Phase NICHT übernommen)
</li>
{/if}
{#if parsed.skipped > 0}
<li>{parsed.skipped} übersprungen (unbekannter Typ)</li>
{/if}
</ul>
{#if parsed.warnings.length > 0}
<details class="text-xs text-[var(--color-muted)]">
<summary class="cursor-pointer">Hinweise ({parsed.warnings.length})</summary>
<ul class="mt-1 list-disc pl-4">
{#each parsed.warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
</ul>
</details>
{/if}
<div class="flex justify-end gap-2 pt-2">
<button
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
onclick={reset}
>
Abbrechen
</button>
<button
class="rounded bg-[var(--color-primary)] px-4 py-1.5 text-sm text-[var(--color-primary-fg)] hover:opacity-90"
onclick={confirmImport}
>
Importieren
</button>
</div>
</div>
{:else if stage === 'importing'}
<div class="py-6 text-center text-sm text-[var(--color-muted)]">
{#if progress.stage === 'decks'}
Lege Decks an · {progress.current} / {progress.total}
{:else if progress.stage === 'cards'}
Importiere Karten · {progress.current} / {progress.total}
{:else}
Fertig.
{/if}
<div class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-[var(--color-border)]/40">
<div
class="h-full bg-[var(--color-primary)] transition-all"
style="width: {progress.total === 0 ? 0 : (progress.current / progress.total) * 100}%"
></div>
</div>
</div>
{:else if stage === 'done' && result}
<div class="space-y-2 text-sm">
<div class="text-[var(--color-success,#16a34a)]">
{result.cardsCreated} Karten in {result.decksCreated}
{result.decksCreated === 1 ? 'Deck' : 'Decks'} angelegt.
</div>
{#if result.failed > 0}
<details class="text-[var(--color-danger)]">
<summary class="cursor-pointer">{result.failed} Fehler</summary>
<ul class="mt-1 list-disc pl-4 text-xs">
{#each result.failures.slice(0, 20) as msg (msg)}<li>{msg}</li>{/each}
</ul>
</details>
{/if}
<button
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
onclick={reset}
>
Weitere Datei
</button>
</div>
{:else if stage === 'error'}
<div class="space-y-2 text-sm">
<div class="text-[var(--color-danger)]">Fehler: {error}</div>
<button
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
onclick={reset}
>
Erneut versuchen
</button>
</div>
{/if}
</div>

View file

@ -27,6 +27,11 @@
class="hover:text-[var(--color-primary)]" class="hover:text-[var(--color-primary)]"
class:font-medium={page.url.pathname.startsWith('/study')}>Lernen</a class:font-medium={page.url.pathname.startsWith('/study')}>Lernen</a
> >
<a
href="/import"
class="hover:text-[var(--color-primary)]"
class:font-medium={page.url.pathname.startsWith('/import')}>Import</a
>
</nav> </nav>
<div class="flex items-center gap-3 text-sm"> <div class="flex items-center gap-3 text-sm">

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import AnkiImport from '$lib/components/AnkiImport.svelte';
onMount(() => {
if (!devUser.id) {
goto('/');
}
});
</script>
<svelte:head>
<title>Import · Cards</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="text-2xl font-semibold">Importieren</h1>
<p class="mt-2 text-sm text-[var(--color-muted)]">
Übernimm Decks und Karten aus einer Anki-Datei (<code>.apkg</code> oder <code>.colpkg</code>).
FSRS-Verlauf wird nicht übernommen — alle Karten starten als „neu".
</p>
<div class="mt-6">
<AnkiImport />
</div>
<aside class="mt-8 rounded-lg border border-dashed border-[var(--color-border)] p-4 text-xs text-[var(--color-muted)]">
<div class="mb-1 font-medium text-[var(--color-fg)]">Was wird übernommen</div>
<ul class="list-disc pl-4">
<li>Decks (Anki-Hierarchie <code>Foo::Bar</code> wird zu <code>Foo / Bar</code>).</li>
<li>Basic + Basic-Reverse: Front/Back direkt.</li>
<li>Cloze: <code>{'{{c1::…}}'}</code> wird mit Sub-Index pro Cluster angelegt.</li>
</ul>
<div class="mt-2 mb-1 font-medium text-[var(--color-fg)]">Was nicht übernommen wird</div>
<ul class="list-disc pl-4">
<li>Bilder + Audio (kommen mit der Plattform-Anbindung in einer späteren Phase).</li>
<li>FSRS-Lernverlauf (Anki-Reviews werden bewusst neu aufgesetzt).</li>
<li>Add-on-spezifische Card-Types (image-occlusion etc.).</li>
</ul>
</aside>
</div>

BIN
apps/web/static/sql-wasm.wasm Executable file

Binary file not shown.

View file

@ -0,0 +1,169 @@
/**
* 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**.');
});
});

126
pnpm-lock.yaml generated
View file

@ -34,7 +34,7 @@ importers:
version: link:../../packages/cards-domain version: link:../../packages/cards-domain
drizzle-orm: drizzle-orm:
specifier: '0.38' specifier: '0.38'
version: 0.38.4(bun-types@1.3.13)(postgres@3.4.9) version: 0.38.4(@types/sql.js@1.4.11)(bun-types@1.3.13)(postgres@3.4.9)(sql.js@1.14.1)
hono: hono:
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.12.18 version: 4.12.18
@ -63,9 +63,15 @@ importers:
dompurify: dompurify:
specifier: ^3.4.2 specifier: ^3.4.2
version: 3.4.2 version: 3.4.2
jszip:
specifier: ^3.10.1
version: 3.10.1
marked: marked:
specifier: ^18.0.3 specifier: ^18.0.3
version: 18.0.3 version: 18.0.3
sql.js:
specifier: ^1.14.1
version: 1.14.1
devDependencies: devDependencies:
'@sveltejs/adapter-node': '@sveltejs/adapter-node':
specifier: ^5.2.0 specifier: ^5.2.0
@ -82,6 +88,12 @@ importers:
'@types/dompurify': '@types/dompurify':
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.2.0 version: 3.2.0
'@types/jszip':
specifier: ^3.4.1
version: 3.4.1
'@types/sql.js':
specifier: ^1.4.11
version: 1.4.11
svelte: svelte:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.55.5 version: 5.55.5
@ -889,18 +901,28 @@ packages:
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/emscripten@1.41.5':
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/jszip@3.4.1':
resolution: {integrity: sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==}
deprecated: This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.
'@types/node@22.19.18': '@types/node@22.19.18':
resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==} resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==}
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/sql.js@1.4.11':
resolution: {integrity: sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==}
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@ -983,6 +1005,9 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@ -1198,6 +1223,12 @@ packages:
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
engines: {node: '>=16.9.0'} engines: {node: '>=16.9.0'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
is-core-module@2.16.2: is-core-module@2.16.2:
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1211,6 +1242,9 @@ packages:
is-reference@3.0.3: is-reference@3.0.3:
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@3.1.5: isexe@3.1.5:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1219,10 +1253,16 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
kleur@4.1.5: kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@ -1323,6 +1363,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@ -1359,6 +1402,12 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readdirp@4.1.2: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@ -1380,6 +1429,9 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
semver@7.7.4: semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1388,6 +1440,9 @@ packages:
set-cookie-parser@3.1.0: set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shell-quote@1.8.3: shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1410,12 +1465,18 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
sql.js@1.14.1:
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0: std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
supports-preserve-symlinks-flag@1.0.0: supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1477,6 +1538,9 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite-node@2.1.9: vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@ -2071,16 +2135,27 @@ snapshots:
dependencies: dependencies:
dompurify: 3.4.2 dompurify: 3.4.2
'@types/emscripten@1.41.5': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/jszip@3.4.1':
dependencies:
jszip: 3.10.1
'@types/node@22.19.18': '@types/node@22.19.18':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/sql.js@1.4.11':
dependencies:
'@types/emscripten': 1.41.5
'@types/node': 22.19.18
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
'@vitest/expect@2.1.9': '@vitest/expect@2.1.9':
@ -2159,6 +2234,8 @@ snapshots:
cookie@0.6.0: {} cookie@0.6.0: {}
core-util-is@1.0.3: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@ -2185,10 +2262,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
drizzle-orm@0.38.4(bun-types@1.3.13)(postgres@3.4.9): drizzle-orm@0.38.4(@types/sql.js@1.4.11)(bun-types@1.3.13)(postgres@3.4.9)(sql.js@1.14.1):
optionalDependencies: optionalDependencies:
'@types/sql.js': 1.4.11
bun-types: 1.3.13 bun-types: 1.3.13
postgres: 3.4.9 postgres: 3.4.9
sql.js: 1.14.1
enhanced-resolve@5.21.2: enhanced-resolve@5.21.2:
dependencies: dependencies:
@ -2331,6 +2410,10 @@ snapshots:
hono@4.12.18: {} hono@4.12.18: {}
immediate@3.0.6: {}
inherits@2.0.4: {}
is-core-module@2.16.2: is-core-module@2.16.2:
dependencies: dependencies:
hasown: 2.0.3 hasown: 2.0.3
@ -2345,12 +2428,25 @@ snapshots:
dependencies: dependencies:
'@types/estree': 1.0.9 '@types/estree': 1.0.9
isarray@1.0.0: {}
isexe@3.1.5: {} isexe@3.1.5: {}
jiti@2.7.0: {} jiti@2.7.0: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
kleur@4.1.5: {} kleur@4.1.5: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
optional: true optional: true
@ -2418,6 +2514,8 @@ snapshots:
nanoid@3.3.12: {} nanoid@3.3.12: {}
pako@1.0.11: {}
path-parse@1.0.7: {} path-parse@1.0.7: {}
pathe@1.1.2: {} pathe@1.1.2: {}
@ -2443,6 +2541,18 @@ snapshots:
prettier@3.8.3: {} prettier@3.8.3: {}
process-nextick-args@2.0.1: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readdirp@4.1.2: {} readdirp@4.1.2: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
@ -2489,10 +2599,14 @@ snapshots:
dependencies: dependencies:
mri: 1.2.0 mri: 1.2.0
safe-buffer@5.1.2: {}
semver@7.7.4: {} semver@7.7.4: {}
set-cookie-parser@3.1.0: {} set-cookie-parser@3.1.0: {}
setimmediate@1.0.5: {}
shell-quote@1.8.3: {} shell-quote@1.8.3: {}
siginfo@2.0.0: {} siginfo@2.0.0: {}
@ -2512,10 +2626,16 @@ snapshots:
source-map@0.6.1: {} source-map@0.6.1: {}
sql.js@1.14.1: {}
stackback@0.0.2: {} stackback@0.0.2: {}
std-env@3.10.0: {} std-env@3.10.0: {}
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3): svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3):
@ -2582,6 +2702,8 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
util-deprecate@1.0.2: {}
vite-node@2.1.9(@types/node@22.19.18)(lightningcss@1.32.0): vite-node@2.1.9(@types/node@22.19.18)(lightningcss@1.32.0):
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14