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>
149 lines
4.6 KiB
TypeScript
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, ' ');
|
|
}
|