mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(cards-web): Anki .apkg import — first acquisition lever
Anki users have decks they hate the UI for but can't migrate. This
gives them a one-drop path: drop a .apkg on the homepage, see a
preview, confirm, the cards land in our DB and start syncing.
Pipeline (lib/anki/):
• parse.ts — JSZip → sql.js (WASM SQLite) → walk Anki's three core
tables (col, notes, cards). Models (col.models JSON) classify each
note: type=0 → basic / basic-reverse, type=1 → cloze. Anki cards
table has one row per generated learnable unit (basic-reverse = 2,
cloze = N) — we dedupe at the note level since our model
regenerates those automatically via reviewStore.ensureReviewsForCard.
• import.ts — every Anki deck becomes one of ours (1:1, "::" → " / ");
fields go through sanitizeAnkiHtml (drops <img>, [sound:], maps
<b>/<i> to Markdown). Orphans land in a fallback "Anki-Import"
deck.
UI: AnkiImport.svelte on the decks list — drag-drop or click,
parse → preview ("X Decks, Y Karten"), confirm → import. No images,
no audio, no review history (cards are FSRS-new on import) — those
are Phase 2.
Deps: sql.js 1.14, jszip 3.10, @types/sql.js. WASM blob copied into
static/ so SvelteKit serves it at /sql-wasm.wasm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0c2df08149
commit
22cce59c3a
7 changed files with 706 additions and 239 deletions
|
|
@ -17,6 +17,7 @@
|
|||
"@sveltejs/vite-plugin-svelte": "^5.0.4",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.17",
|
||||
|
|
@ -37,6 +38,8 @@
|
|||
"@mana/shared-theme": "workspace:*",
|
||||
"@mana/shared-types": "workspace:*",
|
||||
"@mana/shared-utils": "workspace:*",
|
||||
"dexie": "^4.4.1"
|
||||
"dexie": "^4.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"sql.js": "^1.14.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
apps/cards/apps/web/src/lib/anki/import.ts
Normal file
92
apps/cards/apps/web/src/lib/anki/import.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Apply a `ParsedAnki` to the local DB.
|
||||
*
|
||||
* Strategy: every Anki deck becomes one of our decks (1:1, name-mapped).
|
||||
* Card content is HTML-sanitized to plain Markdown before save. Reviews
|
||||
* are auto-generated by reviewStore.ensureReviewsForCard — the imported
|
||||
* cards become "new" in the FSRS sense, no inherited schedule.
|
||||
*
|
||||
* No de-dupe: re-importing the same .apkg adds duplicate decks. The UI
|
||||
* warns about this once we decide it matters.
|
||||
*/
|
||||
|
||||
import { deckStore } from '../stores/decks.svelte';
|
||||
import { cardStore } from '../stores/cards.svelte';
|
||||
import { sanitizeAnkiHtml, type ParsedAnki } from './parse';
|
||||
|
||||
export interface ImportResult {
|
||||
decksCreated: number;
|
||||
cardsCreated: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export async function importParsedAnki(parsed: ParsedAnki): Promise<ImportResult> {
|
||||
const result: ImportResult = { decksCreated: 0, cardsCreated: 0, failed: 0 };
|
||||
|
||||
// Anki deck names use "::" as a separator for nesting — flatten with
|
||||
// a slash so the user sees a meaningful single-line title and we
|
||||
// don't have to invent a hierarchy concept yet.
|
||||
const ankiIdToDeckId = new Map<string, string>();
|
||||
for (const ankiDeck of parsed.decks) {
|
||||
const title = ankiDeck.name.replace(/::/g, ' / ');
|
||||
const created = await deckStore.createDeck({ title, description: 'Aus Anki importiert' });
|
||||
if (!created) {
|
||||
result.failed++;
|
||||
continue;
|
||||
}
|
||||
ankiIdToDeckId.set(ankiDeck.ankiId, created.id);
|
||||
result.decksCreated++;
|
||||
}
|
||||
|
||||
// Cards whose Anki deck wasn't in the parsed list (e.g. the implicit
|
||||
// "Default" deck Anki uses for orphans) get a fallback deck so we
|
||||
// don't drop any user content.
|
||||
const ensureFallbackDeck = (() => {
|
||||
let id: string | null = null;
|
||||
return async () => {
|
||||
if (id) return id;
|
||||
const created = await deckStore.createDeck({
|
||||
title: 'Anki-Import',
|
||||
description: 'Karten ohne explizites Quell-Deck',
|
||||
});
|
||||
if (created) {
|
||||
id = created.id;
|
||||
result.decksCreated++;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
})();
|
||||
|
||||
let orderByDeck = new Map<string, number>();
|
||||
for (const card of parsed.cards) {
|
||||
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);
|
||||
}
|
||||
|
||||
const order = orderByDeck.get(targetDeckId) ?? 0;
|
||||
orderByDeck.set(targetDeckId, order + 1);
|
||||
|
||||
const created = await cardStore.createCard(
|
||||
{ deckId: targetDeckId, type: card.type, fields: cleanFields },
|
||||
order
|
||||
);
|
||||
if (created) {
|
||||
result.cardsCreated++;
|
||||
} else {
|
||||
result.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
193
apps/cards/apps/web/src/lib/anki/parse.ts
Normal file
193
apps/cards/apps/web/src/lib/anki/parse.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* 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: basic + basic-reverse + cloze. Image/audio media is
|
||||
* skipped (Phase 2). Review history is skipped — FSRS state will be
|
||||
* regenerated on first sight.
|
||||
*/
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import initSqlJs, { type Database } from 'sql.js';
|
||||
import type { CardType } from '@mana/cards-core';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
return extract(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function extract(db: Database): ParsedAnki {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Strip Anki's HTML / image / sound markup down to plain text + Markdown.
|
||||
* Conservative — keeps line breaks and bold/italic but strips images
|
||||
* and sound refs (Phase-2 will re-import media). */
|
||||
export function sanitizeAnkiHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<img[^>]*>/g, '')
|
||||
.replace(/\[sound:[^\]]+\]/g, '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/?(?:b|strong)>/gi, '**')
|
||||
.replace(/<\/?(?:i|em)>/gi, '*')
|
||||
.replace(/<\/?p>/gi, '\n')
|
||||
.replace(/<\/?div>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '') // drop remaining tags
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
152
apps/cards/apps/web/src/lib/components/AnkiImport.svelte
Normal file
152
apps/cards/apps/web/src/lib/components/AnkiImport.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import { parseApkg, type ParsedAnki } from '$lib/anki/parse';
|
||||
import { importParsedAnki, type ImportResult } from '$lib/anki/import';
|
||||
|
||||
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>('');
|
||||
|
||||
async function handleFile(file: File) {
|
||||
error = null;
|
||||
fileName = file.name;
|
||||
stage = 'parsing';
|
||||
try {
|
||||
parsed = await parseApkg(file);
|
||||
stage = 'preview';
|
||||
} catch (e: any) {
|
||||
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';
|
||||
try {
|
||||
result = await importParsedAnki(parsed);
|
||||
stage = 'done';
|
||||
} catch (e: any) {
|
||||
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-neutral-800 bg-neutral-900 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="rounded-lg border-2 border-dashed border-neutral-700 px-4 py-6 text-center text-sm text-neutral-400 transition-colors hover:border-indigo-400 hover:text-neutral-200"
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
<div class="mb-1">📦 .apkg-Datei hier ablegen oder klicken</div>
|
||||
<div class="text-xs text-neutral-500">
|
||||
Basic, Basic + Reverse und Cloze werden importiert. Bilder/Audio bleiben raus.
|
||||
</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-neutral-400">Lese {fileName}…</div>
|
||||
{:else if stage === 'preview' && parsed}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-neutral-400">Gefunden in</span>
|
||||
<code class="rounded bg-neutral-800 px-1 text-xs">{fileName}</code>:
|
||||
</div>
|
||||
<ul class="ml-4 list-disc text-neutral-300">
|
||||
<li>{parsed.decks.length} {parsed.decks.length === 1 ? 'Deck' : 'Decks'}</li>
|
||||
<li>{parsed.cards.length} {parsed.cards.length === 1 ? 'Karte' : 'Karten'}</li>
|
||||
{#if parsed.skipped > 0}
|
||||
<li class="text-amber-400">{parsed.skipped} übersprungen (unbekannter Typ)</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{#if parsed.warnings.length > 0}
|
||||
<details class="text-xs text-neutral-500">
|
||||
<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-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"
|
||||
onclick={reset}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400"
|
||||
onclick={confirmImport}
|
||||
>
|
||||
Importieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if stage === 'importing'}
|
||||
<div class="py-6 text-center text-sm text-neutral-400">
|
||||
Importiere {parsed?.cards.length ?? 0} Karten…
|
||||
</div>
|
||||
{:else if stage === 'done' && result}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="text-green-400">
|
||||
✓ {result.cardsCreated} Karten in {result.decksCreated}
|
||||
{result.decksCreated === 1 ? 'Deck' : 'Decks'} angelegt.
|
||||
</div>
|
||||
{#if result.failed > 0}
|
||||
<div class="text-amber-400">{result.failed} Karten konnten nicht angelegt werden.</div>
|
||||
{/if}
|
||||
<button
|
||||
class="rounded-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"
|
||||
onclick={reset}
|
||||
>
|
||||
Weitere Datei
|
||||
</button>
|
||||
</div>
|
||||
{:else if stage === 'error'}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="text-red-400">Fehler: {error}</div>
|
||||
<button
|
||||
class="rounded-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"
|
||||
onclick={reset}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { useAllDecks, useDueCountByDeck } from '$lib/queries';
|
||||
import { deckStore } from '$lib/stores/decks.svelte';
|
||||
import AnkiImport from '$lib/components/AnkiImport.svelte';
|
||||
import type { Deck } from '@mana/cards-core';
|
||||
|
||||
const decksQuery = $derived(useAllDecks());
|
||||
|
|
@ -140,7 +141,9 @@
|
|||
</ul>
|
||||
{/if}
|
||||
|
||||
<p class="mt-12 text-center text-xs text-neutral-600">
|
||||
Phase 1 Vorschau · noch keine Sync zur Mana-App
|
||||
</p>
|
||||
<div class="mt-10">
|
||||
<AnkiImport />
|
||||
</div>
|
||||
|
||||
<p class="mt-12 text-center text-xs text-neutral-600">Phase 1 · synct mit mana.how/cards</p>
|
||||
</main>
|
||||
|
|
|
|||
BIN
apps/cards/apps/web/static/sql-wasm.wasm
Executable file
BIN
apps/cards/apps/web/static/sql-wasm.wasm
Executable file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue