/** * 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. * * Phase-9j-Re-Import-Dedupe: Vor dem Insert wird der content_hash der * Karte berechnet (gleiche Funktion wie der Server) und gegen die * existierende Hash-Liste des Users geprüft. Duplikate werden gezählt * und übersprungen — Re-Imports bringen also keine doppelten Karten * mehr ins Deck. Decks werden nicht dedupliziert (gewollt: zwei * .apkg-Files mit identischen Decknamen sollen sich nicht * versehentlich zusammenführen). */ import { cardContentHash } from '@cards/domain'; import { createDeck } from '$lib/api/decks.ts'; import { createCard, listCardHashes } from '$lib/api/cards.ts'; import { sanitizeAnkiHtml, type ParsedAnki } from './parse.ts'; export interface ImportResult { decksCreated: number; cardsCreated: number; cardsSkippedDuplicate: 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 { const result: ImportResult = { decksCreated: 0, cardsCreated: 0, cardsSkippedDuplicate: 0, failed: 0, failures: [], }; // Vor dem Insert die Hash-Liste des Users laden — wenn der Endpoint // fehlschlägt (z.B. älterer Server vor Phase 9j), fallen wir // stillschweigend auf "kein Dedupe" zurück. const existingHashes = new Set(); try { const r = await listCardHashes(); for (const h of r.hashes) existingHashes.add(h); } catch { // Dedupe bleibt aus — Karten werden eingefügt wie zuvor. } // 1) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen. const ankiIdToDeckId = new Map(); 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 => { 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, content_hash prüfen, einfügen. for (let i = 0; i < parsed.cards.length; i++) { opts.onProgress?.({ stage: 'cards', current: i, total: parsed.cards.length }); const card = parsed.cards[i]; const cleanFields: Record = {}; for (const [key, value] of Object.entries(card.fields)) { cleanFields[key] = sanitizeAnkiHtml(value); } const hash = await cardContentHash({ type: card.type, fields: cleanFields }); if (existingHashes.has(hash)) { result.cardsSkippedDuplicate++; continue; } let targetDeckId = ankiIdToDeckId.get(card.ankiDeckId); if (!targetDeckId) { const fallback = await ensureFallbackDeck(); if (!fallback) { result.failed++; continue; } targetDeckId = fallback; } try { await createCard({ deck_id: targetDeckId, type: card.type, fields: cleanFields, }); result.cardsCreated++; // Hash sofort merken — derselbe Import könnte zwei identische // Karten enthalten (Anki-Drift), zweite würde sonst auch rein. existingHashes.add(hash); } 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 { const first = Object.values(fields)[0] ?? ''; const trimmed = first.length > 40 ? first.slice(0, 40) + '…' : first; return trimmed.replace(/\s+/g, ' '); }