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>
61 lines
1.6 KiB
TypeScript
61 lines
1.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
|
|
import { cardContentHash } from '../src/content-hash.ts';
|
|
|
|
describe('cardContentHash', () => {
|
|
it('liefert deterministischen 64-char-hex-String (SHA-256)', async () => {
|
|
const h = await cardContentHash({
|
|
type: 'basic',
|
|
fields: { front: 'Q', back: 'A' },
|
|
});
|
|
expect(h).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('ist invariant gegenüber Field-Reihenfolge', async () => {
|
|
const a = await cardContentHash({
|
|
type: 'basic',
|
|
fields: { front: 'Q', back: 'A' },
|
|
});
|
|
const b = await cardContentHash({
|
|
type: 'basic',
|
|
fields: { back: 'A', front: 'Q' },
|
|
});
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it('unterscheidet basic und basic-reverse', async () => {
|
|
const a = await cardContentHash({
|
|
type: 'basic',
|
|
fields: { front: 'Q', back: 'A' },
|
|
});
|
|
const b = await cardContentHash({
|
|
type: 'basic-reverse',
|
|
fields: { front: 'Q', back: 'A' },
|
|
});
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it('unterscheidet zwei Cloze-Karten mit unterschiedlichem Cluster-Markup', async () => {
|
|
const a = await cardContentHash({
|
|
type: 'cloze',
|
|
fields: { text: 'Die {{c1::Hauptstadt}} ist {{c2::Paris}}.' },
|
|
});
|
|
const b = await cardContentHash({
|
|
type: 'cloze',
|
|
fields: { text: 'Die Hauptstadt ist {{c1::Paris}}.' },
|
|
});
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it('unterscheidet Karten mit Whitespace-Drift', async () => {
|
|
const a = await cardContentHash({
|
|
type: 'basic',
|
|
fields: { front: 'Q', back: 'A' },
|
|
});
|
|
const b = await cardContentHash({
|
|
type: 'basic',
|
|
fields: { front: 'Q ', back: 'A' },
|
|
});
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|