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:
parent
0b609c46fd
commit
2ca09fe0c3
9 changed files with 916 additions and 3 deletions
209
apps/web/src/lib/components/AnkiImport.svelte
Normal file
209
apps/web/src/lib/components/AnkiImport.svelte
Normal 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>
|
||||
|
|
@ -27,6 +27,11 @@
|
|||
class="hover:text-[var(--color-primary)]"
|
||||
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>
|
||||
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue