cards/apps/web/src/lib/anki/import.ts
Till JS 593d4475df Phase 9j: Anki-Re-Import-Dedupe via content_hash
Neuer Domain-Helper cardContentHash({ type, fields }) — SHA-256 über
canonisierten JSON ({type, sorted-fields}), pure Web-Crypto. Field-
Reihenfolge ist invariant; Whitespace + Cloze-Markup zählen mit
(zwei Karten mit identischem Text aber unterschiedlichem
{{c1::…}}-Markup sind verschiedene Karten).

cards-API POST schreibt content_hash automatisch in den schon
existierenden Schema-Slot. Neuer Endpoint GET /api/v1/cards/hashes
liefert die kompakte Hash-Liste des Users (ohne Card-Body) — eine
Anfrage pro Anki-Import statt pro Karte.

apps/web/src/lib/anki/import.ts holt die Hashes vor dem Loop und
prüft pro Karte clientseitig. Duplikate werden gezählt
(cardsSkippedDuplicate) und übersprungen, der Counter erscheint
in der AnkiImport-Done-View. Same-File-Drift (Anki-interne
Doppel-Notes) wird auch erkannt — nach erfolgreichem Insert
landet der Hash sofort im Set.

Fallback: wenn /hashes fehlschlägt (älterer Server), bleibt das
Dedupe-Set leer und Karten werden eingefügt wie zuvor — kein
Hard-Bruch.

Pre-Phase-9j-Karten haben null content_hash (Hashes-Endpoint
filtert sie weg) — sie können also irrtümlich erneut eingespielt
werden, falls noch im Anki-File. Pragmatisch akzeptiert: ein
Backfill-Script wäre Phase-10-Polish, sobald Live-User da sind.

5 neue Domain-Tests, 1 neuer API-Auth-Gate-Test (105 grün ges.:
51 + 49 + 5). svelte-check 380 files 0 errors. E2E gegen lokale
Postgres bestätigt: neue Karte hat content_hash (64-char-hex),
/hashes listet sie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:29:56 +02:00

149 lines
4.6 KiB
TypeScript

/**
* 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<ImportResult> {
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<string>();
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<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, 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<string, string> = {};
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, string>): string {
const first = Object.values(fields)[0] ?? '';
const trimmed = first.length > 40 ? first.slice(0, 40) + '…' : first;
return trimmed.replace(/\s+/g, ' ');
}