feat(text-only): Wordeck-Cutoff für Image-Occlusion + Audio + MinIO
Some checks are pending
CI / validate (push) Waiting to run

Ω-1: Text-Only-Architektur ist scharfgestellt.

Code-Cleanup:
- 4 Components gelöscht: ImageOcclusionEditor, ImageOcclusionView,
  AudioFrontView, AudioUploadField
- 3 API-Module gelöscht: routes/media.ts, services/storage.ts,
  db/schema/media.ts (mediaRefs + mediaFiles), routes/decks-from-image.ts
- packages/cards-domain: image-occlusion.ts + Tests entfernt,
  CardTypeSchema reduziert auf basic/basic-reverse/cloze/typing/multiple-choice
- 3 Web-Routes (study/[deckId], cards/new, cards/[id]/edit) bereinigt:
  Image-Occlusion- und Audio-Front-Code-Pfade raus
- anki/import.ts text-only: kein Media-Upload mehr, img/sound werden
  ersatzlos gestrippt
- 21 weitere Files bereinigt: dto, health, me, dsgvo, tools, cards,
  decks, share-handlers, marketplace/decks, marketplace/fork,
  marketplace/pull-requests, AnkiImport.svelte

DB-Migrationen (noch nicht gerannt, idempotent):
- 0004_wordeck_text_only.sql: DELETE image-occlusion/audio (0 betroffene
  Rows), media_files-Tabelle DROP, media_refs-Spalte DROP, CHECK
  cards.type IN (basic, basic-reverse, cloze, type-in, multiple-choice)
- 0005_wordeck_license_rename.sql: Cardecky-Personal-Use-1.0 →
  Wordeck-Personal-Use-1.0, Cardecky-Pro-Only-1.0 → Wordeck-Pro-Only-1.0,
  Default + CHECK + Backfill

Infrastruktur:
- docker-compose.production.yml: cards-minio-Service raus, MinIO-Envs
  aus cards-api raus, CARDS_PUBLIC_URL + PUBLIC_CARDS_API_URL auf
  wordeck.com / api.wordeck.com
- App-Manifest schon vorher auf wordeck umgestellt

Type-Check grün (api, domain, web — alle 3 Sub-Pakete).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-17 21:23:30 +02:00
parent 1228eb4692
commit e3c84a9249
42 changed files with 149 additions and 1955 deletions

View file

@ -0,0 +1,36 @@
-- Wordeck-Rebrand: License-Strings Cardecky-* → Wordeck-*.
--
-- Bisher genutzte Lizenz-Identifier:
-- Cardecky-Personal-Use-1.0 (Free-Default)
-- Cardecky-Pro-Only-1.0 (Paid-Only)
--
-- Diese werden bei der Wordeck-Umbenennung in die Wordeck-Variante
-- migriert (gleiche Bedingungen, neuer Brand).
--
-- 1. CHECK-Constraint anpassen
-- 2. Bestehende Rows umbenennen
-- 3. Default-Wert der Spalte anpassen
--
-- Idempotent (IF EXISTS / IF NOT EXISTS), damit Re-Runs harmlos sind.
-- 1. Alten CHECK-Constraint droppen (er erwartete 'Cardecky-Pro-Only-1.0')
ALTER TABLE "marketplace"."decks"
DROP CONSTRAINT IF EXISTS "decks_price_requires_license";
-- 2. Bestehende Rows umbenennen (Bestand-Migration)
UPDATE "marketplace"."decks"
SET "license" = 'Wordeck-Personal-Use-1.0'
WHERE "license" = 'Cardecky-Personal-Use-1.0';
UPDATE "marketplace"."decks"
SET "license" = 'Wordeck-Pro-Only-1.0'
WHERE "license" = 'Cardecky-Pro-Only-1.0';
-- 3. Default der Spalte umstellen
ALTER TABLE "marketplace"."decks"
ALTER COLUMN "license" SET DEFAULT 'Wordeck-Personal-Use-1.0';
-- 4. Neuen CHECK mit Wordeck-Lizenzen
ALTER TABLE "marketplace"."decks"
ADD CONSTRAINT "decks_price_requires_license"
CHECK (price_credits = 0 OR license = 'Wordeck-Pro-Only-1.0');

View file

@ -9,10 +9,11 @@ import { decks } from './decks.ts';
*
* - basic / basic-reverse: { front, back }
* - cloze: { text, extra? }
* - type-in: { question, expected }
* - image-occlusion: { image_ref, mask_regions: [...] }
* - typing: { front, answer, aliases? }
* - multiple-choice: { front, answer, distractor_pool?, explanation? }
*
* MVP unterstützt nur `basic` und `basic-reverse`.
* Wordeck-Rebrand (2026-05-17): image-occlusion und audio-front sind
* entfallen. Wordeck ist text-only.
*
* `tags` ist ein eigener Tag-Mapping-Layer (siehe `tags.ts` + `cardTags`),
* NICHT in dieser Tabelle gespeichert.
@ -27,7 +28,6 @@ export const cards = cardsSchema.table(
userId: text('user_id').notNull(),
type: text('type').notNull(),
fields: jsonb('fields').notNull(),
mediaRefs: jsonb('media_refs').notNull().$type<string[]>().default([]),
contentHash: text('content_hash'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()

View file

@ -22,14 +22,6 @@ export type {
export { tags } from './tags.ts';
export type { TagRow, TagInsert } from './tags.ts';
export { mediaRefs, mediaFiles } from './media.ts';
export type {
MediaRefRow,
MediaRefInsert,
MediaFileRow,
MediaFileInsert,
} from './media.ts';
export { importJobs } from './imports.ts';
export type { ImportJobRow, ImportJobInsert } from './imports.ts';

View file

@ -9,7 +9,7 @@
* Versions-Bumps hinweg erhalten kann.
*
* `price_credits = 0` heißt kostenlos. Alles > 0 erzwingt die
* Cardecky-Pro-Only-1.0-Lizenz (CHECK constraint auf DB-Ebene).
* Wordeck-Pro-Only-1.0-Lizenz (CHECK constraint auf DB-Ebene).
*/
import { sql } from 'drizzle-orm';
@ -39,8 +39,6 @@ export const cardTypeEnum = marketplaceSchema.enum('card_type', [
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
@ -69,8 +67,8 @@ export const publicDecks = marketplaceSchema.table(
],
}),
// SPDX-style ID. CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0,
// Cardecky-Personal-Use-1.0 (default für free), Cardecky-Pro-Only-1.0 (paid).
license: text('license').notNull().default('Cardecky-Personal-Use-1.0'),
// Wordeck-Personal-Use-1.0 (default für free), Wordeck-Pro-Only-1.0 (paid).
license: text('license').notNull().default('Wordeck-Personal-Use-1.0'),
priceCredits: integer('price_credits').notNull().default(0),
ownerUserId: text('owner_user_id')
.notNull()
@ -90,7 +88,7 @@ export const publicDecks = marketplaceSchema.table(
// Paid Decks müssen die Pro-Only-Lizenz tragen.
priceLicense: check(
'decks_price_requires_license',
sql`price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0'`
sql`price_credits = 0 OR license = 'Wordeck-Pro-Only-1.0'`
),
})
);

View file

@ -1,65 +0,0 @@
import { index, integer, text, timestamp } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts';
import { cards } from './cards.ts';
/**
* Media-Files: Bilder, Audio, Video, die in MinIO unter dem
* `objectKey` liegen und von Karten via cards.media_refs[]
* referenziert werden.
*
* Bewusst ohne FK auf eine konkrete Karte: ein File kann von
* mehreren Karten referenziert werden (z.B. ein Bild für Front
* und Back). Lifecycle-Cleanup per Cron oder DSGVO-Delete.
*
* objectKey-Format: `<userId>/<ulid>.<ext>` UserId-Präfix
* vereinfacht den DSGVO-Delete (Bucket-Prefix-Sweep).
*/
export const mediaFiles = cardsSchema.table(
'media_files',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
objectKey: text('object_key').notNull(),
mimeType: text('mime_type').notNull(),
originalFilename: text('original_filename'),
sizeBytes: integer('size_bytes').notNull(),
kind: text('kind', { enum: ['image', 'audio', 'video', 'other'] }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
},
(t) => ({
userIdx: index('media_files_user_idx').on(t.userId),
})
);
export type MediaFileRow = typeof mediaFiles.$inferSelect;
export type MediaFileInsert = typeof mediaFiles.$inferInsert;
/**
* Legacy: media_refs aus Phase 1, Vor-Sprint-15. Bewusst behalten als
* Sortier-Layer-Slot für später (mana-media-Konvergenz). Aktuell leer.
*/
export const mediaRefs = cardsSchema.table(
'media_refs',
{
id: text('id').primaryKey(),
cardId: text('card_id')
.notNull()
.references(() => cards.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
manaMediaObjectId: text('mana_media_object_id').notNull(),
kind: text('kind', { enum: ['image', 'audio', 'video'] }).notNull(),
ord: integer('ord').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
.notNull()
.defaultNow(),
},
(t) => ({
cardIdx: index('media_card_idx').on(t.cardId),
})
);
export type MediaRefRow = typeof mediaRefs.$inferSelect;
export type MediaRefInsert = typeof mediaRefs.$inferInsert;

View file

@ -12,9 +12,7 @@ import { toolsRouter } from './routes/tools.ts';
import { searchRouter } from './routes/search.ts';
import { dsgvoRouter } from './routes/dsgvo.ts';
import { meRouter } from './routes/me.ts';
import { mediaRouter } from './routes/media.ts';
import { decksGenerateRouter } from './routes/decks-generate.ts';
import { decksFromImageRouter } from './routes/decks-from-image.ts';
import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts';
import { marketplaceDecksRouter } from './routes/marketplace/decks.ts';
import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/explore.ts';
@ -64,9 +62,7 @@ app.route('/api/v1/tools', toolsRouter());
app.route('/api/v1/search', searchRouter());
app.route('/api/v1/dsgvo', dsgvoRouter());
app.route('/api/v1/me', meRouter());
app.route('/api/v1/media', mediaRouter());
app.route('/api/v1/decks/generate', decksGenerateRouter());
app.route('/api/v1/decks/from-image', decksFromImageRouter());
// Marketplace (Phase 12). Eigenes pgSchema, additive Routen unter /v1/marketplace/*.
// Plan: docs/playbooks/MARKETPLACE_RESTORE.md.

View file

@ -26,7 +26,6 @@ export function toCardDto(row: typeof cards.$inferSelect) {
user_id: row.userId,
type: row.type,
fields: row.fields,
media_refs: row.mediaRefs ?? [],
content_hash: row.contentHash,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),

View file

@ -5,7 +5,6 @@ import {
CardCreateSchema,
CardUpdateSchema,
cardContentHash,
maskRegionCount,
subIndexCount,
subIndexCountForCloze,
} from '@cards/domain';
@ -43,9 +42,9 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
}
const userId = c.get('userId');
// Text-abhängige Sub-Index-Counts (Cloze, Image-Occlusion) vor
// dem Deck-Lookup auflösen — Validation-Errors bleiben 422 statt
// versehentlich auf 404 zu fallen.
// Text-abhängige Sub-Index-Counts (Cloze) vor dem Deck-Lookup
// auflösen — Validation-Errors bleiben 422 statt versehentlich
// auf 404 zu fallen.
let count: number;
if (parsed.data.type === 'cloze') {
count = subIndexCountForCloze(parsed.data.fields.text ?? '');
@ -55,17 +54,6 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
422
);
}
} else if (parsed.data.type === 'image-occlusion') {
count = maskRegionCount(parsed.data.fields.mask_regions ?? '');
if (count === 0) {
return c.json(
{
error: 'invalid_input',
issues: ['image-occlusion.mask_regions must be a JSON array with >=1 valid region'],
},
422
);
}
} else {
count = subIndexCount(parsed.data.type);
}
@ -95,7 +83,6 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
userId,
type: parsed.data.type,
fields: parsed.data.fields,
mediaRefs: parsed.data.media_refs ?? [],
contentHash,
createdAt: now,
updatedAt: now,
@ -168,7 +155,6 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }>
.update(cards)
.set({
...(parsed.data.fields !== undefined && { fields: parsed.data.fields }),
...(parsed.data.media_refs !== undefined && { mediaRefs: parsed.data.media_refs }),
updatedAt: new Date(),
})
.where(and(eq(cards.id, id), eq(cards.userId, userId)))

View file

@ -1,191 +0,0 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { getDb, type CardsDb } from '../db/connection.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { rateLimit, userKey } from '../middleware/rate-limit.ts';
import { chatVisionJson } from '../services/llm-client.ts';
import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts';
import { fetchUrlContent } from '../lib/url-fetch.ts';
export type FromImageDeps = { db?: CardsDb };
const MAX_FILES = 5;
const MAX_BYTES_PER_IMAGE = 10 * 1024 * 1024;
const MAX_BYTES_PER_PDF = 30 * 1024 * 1024;
function isAllowedMime(mime: string): boolean {
return mime.startsWith('image/') || mime === 'application/pdf';
}
function maxBytesFor(mime: string): number {
return mime === 'application/pdf' ? MAX_BYTES_PER_PDF : MAX_BYTES_PER_IMAGE;
}
const InputSchema = z.object({
language: z.enum(['de', 'en']).optional().default('de'),
count: z.coerce.number().int().min(1).max(40).optional().default(15),
url: z.string().url().max(2000).optional(),
});
const SYSTEM_PROMPT = `Du bist ein Lerndesigner. Analysiere alle bereitgestellten Inhalte (Bilder, Dokumente, Texte, URLs) und erstelle daraus ein einziges zusammenhängendes Karteikarten-Deck für Spaced-Repetition-Lernen.
Du gibst NUR ein gültiges JSON-Objekt zurück, exakt mit diesem Schema:
{
"deck_name": "<kurzer Titel, max 80 Zeichen>",
"deck_description": "<eine Zeile Beschreibung, optional>",
"cards": [
{ "front": "<Frage oder Begriff>", "back": "<Antwort oder Erklärung>" },
...
]
}
Regeln:
- Front ist Frage / Begriff / Hinweis. Back ist Antwort / Definition / Erklärung.
- Eine Karte = ein Lernstoff-Bissen (atomic). Nicht mehrere Konzepte in eine Karte stopfen.
- Markdown ist erlaubt (**fett**, *kursiv*, Listen, \`code\`).
- KEIN HTML, KEIN Code-Fence außerhalb des JSON, KEINE Erklärung außerhalb des JSON.
- Erstelle ein kohärentes Deck, das den Lernstoff aller Quellen zusammenfasst.`;
export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
// 10/min per User — Vision-LLM-Call ist besonders teuer.
r.use('/', rateLimit({ scope: 'decks.from-image', windowMs: 60_000, max: 10, keyOf: userKey }));
r.post('/', async (c) => {
const userId = c.get('userId');
// Accept multipart (files + optional url) OR json (url-only, no files)
const contentType = c.req.header('Content-Type') ?? '';
let files: File[] = [];
let rawInput: { language?: unknown; count?: unknown; url?: unknown } = {};
if (contentType.includes('multipart/form-data')) {
const form = await c.req.formData().catch(() => null);
if (!form) {
return c.json({ error: 'invalid_input', detail: 'multipart body required' }, 400);
}
const rawFiles = form.getAll('file');
files = rawFiles.filter((f): f is File => f instanceof File && isAllowedMime(f.type));
rawInput = {
language: form.get('language') ?? undefined,
count: form.get('count') ?? undefined,
url: form.get('url') ?? undefined,
};
} else {
const body = await c.req.json().catch(() => null);
if (!body || typeof body !== 'object') {
return c.json({ error: 'invalid_input', detail: 'json or multipart body required' }, 400);
}
rawInput = body as { language?: unknown; count?: unknown; url?: unknown };
}
const parsed = InputSchema.safeParse(rawInput);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422,
);
}
const { language, count, url } = parsed.data;
if (files.length === 0 && !url) {
return c.json(
{ error: 'invalid_input', detail: 'provide at least one image/PDF file or a URL' },
400,
);
}
if (files.length > MAX_FILES) {
return c.json({ error: 'invalid_input', detail: `max ${MAX_FILES} files per request` }, 400);
}
const oversized = files.find((f) => f.size > maxBytesFor(f.type));
if (oversized) {
const limit = oversized.type === 'application/pdf' ? '30 MiB' : '10 MiB';
return c.json(
{ error: 'invalid_input', detail: `"${oversized.name}" exceeds ${limit} limit` },
413,
);
}
const [images, urlContent] = await Promise.all([
Promise.all(
files.map(async (f) => ({
base64: Buffer.from(await f.arrayBuffer()).toString('base64'),
mimeType: f.type,
})),
),
url ? fetchUrlContent(url) : Promise.resolve(null),
]);
// URL-only request where extraction failed → can't generate anything meaningful
if (url && images.length === 0 && !urlContent) {
return c.json(
{ error: 'url_extraction_failed', detail: `could not extract content from ${url}` },
502,
);
}
const sourceParts: string[] = [];
if (images.length > 0) {
const hasPdf = files.some((f) => f.type === 'application/pdf');
sourceParts.push(
hasPdf
? images.length === 1 ? 'einem Dokument' : `${images.length} Dateien`
: images.length === 1 ? 'einem Bild' : `${images.length} Bildern`,
);
}
if (urlContent) sourceParts.push('URL-Inhalt');
const langLabel = language === 'de' ? 'Deutsch' : 'English';
let userText = `Erstelle ${count} Lernkarten auf ${langLabel} aus ${sourceParts.join(' und ')}.`;
if (urlContent) {
userText += `\n\nURL-Kontext (${url}):\n${urlContent}`;
}
let generated: z.infer<typeof GeneratedDeckSchema>;
try {
const raw = await chatVisionJson<unknown>({
images,
systemPrompt: SYSTEM_PROMPT,
userText,
timeoutMs: 120_000,
});
const r2 = GeneratedDeckSchema.safeParse(raw);
if (!r2.success) {
return c.json(
{
error: 'llm_returned_invalid_shape',
issues: r2.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
raw,
},
502,
);
}
generated = r2.data;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return c.json({ error: 'llm_call_failed', detail: msg }, 502);
}
const fallbackParts: string[] = [];
if (images.length > 0) {
const hasPdf = files.some((f) => f.type === 'application/pdf');
fallbackParts.push(
hasPdf
? images.length === 1 ? 'Dokument' : `${images.length} Dateien`
: images.length === 1 ? 'Bild' : `${images.length} Bildern`,
);
}
if (url) fallbackParts.push('URL');
const fallback = `KI-generiert aus ${fallbackParts.join(' + ')}`;
const result = await insertGeneratedDeck(dbOf(), userId, generated, fallback);
return c.json(result, 201);
});
return r;
}

View file

@ -70,7 +70,6 @@ export async function insertGeneratedDeck(
userId,
type: 'basic',
fields: cr.fields,
mediaRefs: [],
contentHash: cr.contentHash,
createdAt: now,
updatedAt: now,

View file

@ -185,7 +185,6 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
userId,
type: card.type,
fields: card.fields as Record<string, string>,
mediaRefs: card.mediaRefs,
contentHash: card.contentHash,
createdAt: now,
updatedAt: now,

View file

@ -6,15 +6,12 @@ import {
cards,
decks,
importJobs,
mediaFiles,
mediaRefs,
reviews,
studySessions,
tags,
} from '../db/schema/index.ts';
import { serviceKeyAuth } from '../middleware/service-key.ts';
import { rateLimit, ipKey } from '../middleware/rate-limit.ts';
import { getStorage } from '../services/storage.ts';
import { auditLog } from '../lib/audit.ts';
export type DsgvoDeps = { db?: CardsDb };
@ -31,8 +28,6 @@ export async function buildUserExport(db: CardsDb, userId: string) {
reviewsRows,
sessionsRows,
tagsRows,
mediaRefRows,
mediaFileRows,
importsRows,
] = await Promise.all([
db.select().from(decks).where(eq(decks.userId, userId)),
@ -40,15 +35,13 @@ export async function buildUserExport(db: CardsDb, userId: string) {
db.select().from(reviews).where(eq(reviews.userId, userId)),
db.select().from(studySessions).where(eq(studySessions.userId, userId)),
db.select().from(tags).where(eq(tags.userId, userId)),
db.select().from(mediaRefs).where(eq(mediaRefs.userId, userId)),
db.select().from(mediaFiles).where(eq(mediaFiles.userId, userId)),
db.select().from(importJobs).where(eq(importJobs.userId, userId)),
]);
return {
user_id: userId,
exported_at: new Date().toISOString(),
app: 'cards',
app: 'wordeck',
app_version: process.env.CARDS_API_VERSION ?? '0.0.0',
data: {
decks: decksRows.map((d) => ({
@ -72,14 +65,6 @@ export async function buildUserExport(db: CardsDb, userId: string) {
finishedAt: s.finishedAt ? s.finishedAt.toISOString() : null,
})),
tags: tagsRows.map((t) => ({ ...t, createdAt: t.createdAt.toISOString() })),
media_refs: mediaRefRows.map((m) => ({
...m,
createdAt: m.createdAt.toISOString(),
})),
media_files: mediaFileRows.map((f) => ({
...f,
createdAt: f.createdAt.toISOString(),
})),
import_jobs: importsRows.map((j) => ({
...j,
createdAt: j.createdAt.toISOString(),
@ -130,16 +115,13 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
});
/**
* Vollständige Löschung aller Cards-Daten eines Users. FK-Cascades
* Vollständige Löschung aller Wordeck-Daten eines Users. FK-Cascades
* räumen automatisch:
* decks cards reviews
* cards media_refs
* cards card_tags
* decks cards reviews / card_tags
* decks tags
* decks study_sessions
* Verbleibend: import_jobs + media_files (eigene Tabellen ohne FK)
* werden separat gelöscht. MinIO-Objects werden per Bucket-Prefix-
* Sweep entfernt (objectKey-Format `<userId>/<ulid>.<ext>`).
* Verbleibend: import_jobs (eigene Tabelle ohne FK) wird separat
* gelöscht. Kein Object-Storage mehr (text-only ab Wordeck-Rebrand).
*/
r.post('/delete', async (c) => {
const body = await c.req.json().catch(() => null);
@ -147,39 +129,17 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
if (!userId) return c.json({ error: 'missing_user_id' }, 400);
const db = dbOf();
const [deletedDecks, deletedImports, deletedMediaFiles] = await db.transaction(
async (tx) => {
const dd = await tx
.delete(decks)
.where(eq(decks.userId, userId))
.returning({ id: decks.id });
const di = await tx
.delete(importJobs)
.where(eq(importJobs.userId, userId))
.returning({ id: importJobs.id });
const dm = await tx
.delete(mediaFiles)
.where(eq(mediaFiles.userId, userId))
.returning({ id: mediaFiles.id });
return [dd, di, dm];
}
);
// MinIO-Bucket-Sweep nach DB-Cleanup. Wenn der Storage-Sweep
// scheitert, ist das nicht-fatal für die DB-Konsistenz (Files
// ohne DB-Eintrag sind tote Bytes), aber der Caller MUSS es
// erfahren — sonst meldet mana-admin dem User „alles gelöscht"
// obwohl Media-Objekte überleben können.
let storageObjectsDeleted = 0;
let storageOk = true;
let storageError: string | null = null;
try {
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
} catch (err) {
storageOk = false;
storageError = err instanceof Error ? err.message : String(err);
console.warn('[dsgvo/delete] storage sweep failed:', err);
}
const [deletedDecks, deletedImports] = await db.transaction(async (tx) => {
const dd = await tx
.delete(decks)
.where(eq(decks.userId, userId))
.returning({ id: decks.id });
const di = await tx
.delete(importJobs)
.where(eq(importJobs.userId, userId))
.returning({ id: importJobs.id });
return [dd, di];
});
auditLog({
event: 'dsgvo.delete',
@ -187,27 +147,19 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
auth_mode: 'service-key',
ip: c.req.header('X-Forwarded-For') ?? null,
user_agent: c.req.header('User-Agent') ?? null,
result: storageOk ? 'success' : 'partial',
result: 'success',
detail: {
decks: deletedDecks.length,
import_jobs: deletedImports.length,
media_files: deletedMediaFiles.length,
storage_objects: storageObjectsDeleted,
storage_ok: storageOk,
storage_error: storageError,
},
});
return c.json({
deleted: true,
user_id: userId,
storage_ok: storageOk,
...(storageError ? { storage_error: storageError } : {}),
counts: {
decks: deletedDecks.length,
import_jobs: deletedImports.length,
media_files: deletedMediaFiles.length,
storage_objects: storageObjectsDeleted,
},
});
});

View file

@ -2,38 +2,33 @@ import { Hono } from 'hono';
import { sql } from 'drizzle-orm';
import { getDb } from '../db/connection.ts';
import { getStorage } from '../services/storage.ts';
export const healthRoute = new Hono();
/**
* Liveness antwortet immer 200, solange der Prozess läuft.
* Bewusst KEIN Downstream-Probe, damit ein kurzer DB-/MinIO-Hänger
* Bewusst KEIN Downstream-Probe, damit ein kurzer DB-Hänger
* nicht den Container in eine Restart-Schleife zwingt.
*/
healthRoute.get('/healthz', (c) => c.json({ status: 'ok' }));
/**
* Readiness mit Downstream-Probes. Status 200 wenn DB + MinIO grün,
* Readiness mit Downstream-Probes. Status 200 wenn DB grün,
* sonst 503 mit Aufschlüsselung welche Probe fehlgeschlagen ist.
* Probes timen sich selbst aus über ein 1s-AbortSignal eine träge
* Abhängigkeit darf das Readiness-Signal nicht hängen lassen.
*/
healthRoute.get('/healthz/details', async (c) => {
const [dbProbe, storageProbe] = await Promise.all([
probeDb(),
probeStorage(),
]);
const allOk = dbProbe.ok && storageProbe.ok;
const dbProbe = await probeDb();
const allOk = dbProbe.ok;
return c.json(
{
status: allOk ? 'ok' : 'degraded',
app: 'cards',
app: 'wordeck',
version: process.env.CARDS_API_VERSION ?? '0.0.0',
uptime_s: Math.floor(process.uptime()),
checks: {
db: dbProbe,
storage: storageProbe,
},
},
allOk ? 200 : 503
@ -42,7 +37,7 @@ healthRoute.get('/healthz/details', async (c) => {
healthRoute.get('/version', (c) =>
c.json({
app: 'cards',
app: 'wordeck',
version: process.env.CARDS_API_VERSION ?? '0.0.0',
build: process.env.CARDS_BUILD_SHA ?? 'dev',
})
@ -61,16 +56,6 @@ async function probeDb(): Promise<ProbeResult> {
}
}
async function probeStorage(): Promise<ProbeResult> {
const t0 = Date.now();
try {
await getStorage().ensureBucket();
return { ok: true, latency_ms: Date.now() - t0 };
} catch (err) {
return { ok: false, error: errorMessage(err) };
}
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);

View file

@ -74,8 +74,6 @@ const CardTypeSchema = z.enum([
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
@ -178,13 +176,13 @@ export function marketplaceDecksRouter(
return c.json({ error: 'invalid_slug', reason: validation.reason }, 422);
}
const license = parsed.data.license ?? 'Cardecky-Personal-Use-1.0';
const license = parsed.data.license ?? 'Wordeck-Personal-Use-1.0';
const priceCredits = parsed.data.priceCredits ?? 0;
if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') {
if (priceCredits > 0 && license !== 'Wordeck-Pro-Only-1.0') {
return c.json(
{
error: 'paid_decks_require_pro_license',
detail: 'priceCredits > 0 ⇒ license must be Cardecky-Pro-Only-1.0',
detail: 'priceCredits > 0 ⇒ license must be Wordeck-Pro-Only-1.0',
},
422
);
@ -248,7 +246,7 @@ export function marketplaceDecksRouter(
const license = parsed.data.license ?? deck.license;
const priceCredits = parsed.data.priceCredits ?? deck.priceCredits;
if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') {
if (priceCredits > 0 && license !== 'Wordeck-Pro-Only-1.0') {
return c.json({ error: 'paid_decks_require_pro_license' }, 422);
}

View file

@ -124,7 +124,6 @@ export async function forkDeckForUser(
userId,
type: sourceCard.type,
fields: sourceCard.fields,
mediaRefs: [],
contentHash: sourceCard.contentHash,
createdAt: now,
updatedAt: now,
@ -342,7 +341,6 @@ export function forkRouter(deps: MarketplaceForkDeps = {}): Hono<{ Variables: Au
userId,
type: card.type,
fields: card.fields,
mediaRefs: [],
contentHash: computedHash,
createdAt: now,
updatedAt: now,

View file

@ -48,8 +48,6 @@ const CardTypeEnum = z.enum([
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
@ -335,8 +333,6 @@ export function pullRequestsRouter(
| 'basic-reverse'
| 'cloze'
| 'type-in'
| 'image-occlusion'
| 'audio'
| 'multiple-choice',
fields: card.fields,
ord: card.ord,

View file

@ -2,9 +2,8 @@ import { and, desc, eq, gte, isNotNull, lte, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { getDb, type CardsDb } from '../db/connection.ts';
import { cards, decks, importJobs, mediaFiles, reviews } from '../db/schema/index.ts';
import { cards, decks, importJobs, reviews } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { getStorage } from '../services/storage.ts';
import { buildUserExport } from './dsgvo.ts';
import { auditLog } from '../lib/audit.ts';
@ -19,15 +18,11 @@ function frontSnippetFor(type: string, fields: Record<string, string>): string {
const raw =
type === 'cloze'
? (fields.text ?? '')
: type === 'image-occlusion'
? '[Bild-Karte]'
: type === 'audio-front'
? (fields.front ?? '[Audio-Karte]')
: type === 'typing'
? (fields.question ?? fields.front ?? '')
: type === 'multiple-choice'
? (fields.question ?? fields.front ?? '')
: (fields.front ?? '');
: type === 'typing'
? (fields.question ?? fields.front ?? '')
: type === 'multiple-choice'
? (fields.question ?? fields.front ?? '')
: (fields.front ?? '');
const cleaned = raw
.replace(/\{\{c\d+::([^:}]*)(?:::[^}]*)?\}\}/g, '$1')
.replace(/[*_`#>]/g, '')
@ -353,34 +348,17 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
r.post('/delete', async (c) => {
const userId = c.get('userId');
const db = dbOf();
const [deletedDecks, deletedImports, deletedMediaFiles] = await db.transaction(
async (tx) => {
const dd = await tx
.delete(decks)
.where(eq(decks.userId, userId))
.returning({ id: decks.id });
const di = await tx
.delete(importJobs)
.where(eq(importJobs.userId, userId))
.returning({ id: importJobs.id });
const dm = await tx
.delete(mediaFiles)
.where(eq(mediaFiles.userId, userId))
.returning({ id: mediaFiles.id });
return [dd, di, dm];
}
);
let storageObjectsDeleted = 0;
let storageOk = true;
let storageError: string | null = null;
try {
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
} catch (err) {
storageOk = false;
storageError = err instanceof Error ? err.message : String(err);
console.warn('[me/delete] storage sweep failed:', err);
}
const [deletedDecks, deletedImports] = await db.transaction(async (tx) => {
const dd = await tx
.delete(decks)
.where(eq(decks.userId, userId))
.returning({ id: decks.id });
const di = await tx
.delete(importJobs)
.where(eq(importJobs.userId, userId))
.returning({ id: importJobs.id });
return [dd, di];
});
auditLog({
event: 'dsgvo.delete',
@ -389,27 +367,19 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
auth_mode: c.get('authMode'),
ip: c.req.header('X-Forwarded-For') ?? null,
user_agent: c.req.header('User-Agent') ?? null,
result: storageOk ? 'success' : 'partial',
result: 'success',
detail: {
decks: deletedDecks.length,
import_jobs: deletedImports.length,
media_files: deletedMediaFiles.length,
storage_objects: storageObjectsDeleted,
storage_ok: storageOk,
storage_error: storageError,
},
});
return c.json({
deleted: true,
user_id: userId,
storage_ok: storageOk,
...(storageError ? { storage_error: storageError } : {}),
counts: {
decks: deletedDecks.length,
import_jobs: deletedImports.length,
media_files: deletedMediaFiles.length,
storage_objects: storageObjectsDeleted,
},
});
});

View file

@ -1,161 +0,0 @@
import { and, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { getDb, type CardsDb } from '../db/connection.ts';
import { mediaFiles, type MediaFileRow } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { rateLimit, userKey } from '../middleware/rate-limit.ts';
import { ulid } from '../lib/ulid.ts';
import { getStorage, type StorageService } from '../services/storage.ts';
export type MediaDeps = { db?: CardsDb; storage?: StorageService };
const MAX_BYTES = Number(process.env.CARDS_MEDIA_MAX_BYTES ?? 25 * 1024 * 1024); // 25 MiB
const ALLOWED_PREFIXES = ['image/', 'audio/', 'video/'];
function kindFor(mime: string): MediaFileRow['kind'] {
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('video/')) return 'video';
return 'other';
}
function extFor(mime: string, fallback?: string): string {
const map: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'audio/mpeg': 'mp3',
'audio/ogg': 'ogg',
'audio/wav': 'wav',
'audio/mp4': 'm4a',
'video/mp4': 'mp4',
'video/webm': 'webm',
};
if (map[mime]) return map[mime];
if (fallback) {
const dot = fallback.lastIndexOf('.');
if (dot > 0 && dot < fallback.length - 1) return fallback.slice(dot + 1).toLowerCase();
}
return 'bin';
}
export function mediaRouter(deps: MediaDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
const storageOf = () => deps.storage ?? getStorage();
r.use('*', authMiddleware);
// 30/min per User — Upload ist teuer (Storage + DB-Insert), aber
// ein Anki-Import kann viele Files in Folge schicken; das Limit
// soll Bursts ermöglichen, aber Dauer-Spam stoppen.
r.use('/upload', rateLimit({ scope: 'media.upload', windowMs: 60_000, max: 30, keyOf: userKey }));
/**
* Multipart-Upload eines einzelnen Files. Bilder/Audio/Video; alles
* andere wird mit 415 abgelehnt. Limit per env (Default 25 MiB).
*
* Antwort:
* { id, url, mime_type, kind, size_bytes, original_filename }
*
* `url` ist ein relativer App-Pfad (`/api/v1/media/<id>`), den
* Frontend + Anki-Importer in `<img src=...>` einsetzen können.
* Public absolute URL kommt erst mit Phase 10 + DNS dazu.
*/
r.post('/upload', async (c) => {
const userId = c.get('userId');
const form = await c.req.formData().catch(() => null);
if (!form) return c.json({ error: 'expected_multipart' }, 400);
const file = form.get('file');
if (!(file instanceof File)) return c.json({ error: 'missing_file_field' }, 400);
const mime = file.type || 'application/octet-stream';
if (!ALLOWED_PREFIXES.some((p) => mime.startsWith(p))) {
return c.json({ error: 'unsupported_media_type', mime_type: mime }, 415);
}
if (file.size > MAX_BYTES) {
return c.json({ error: 'too_large', max_bytes: MAX_BYTES, got: file.size }, 413);
}
const id = ulid();
const ext = extFor(mime, file.name);
const objectKey = `${userId}/${id}.${ext}`;
const buf = new Uint8Array(await file.arrayBuffer());
await storageOf().putObject(objectKey, buf, mime);
const [row] = await dbOf()
.insert(mediaFiles)
.values({
id,
userId,
objectKey,
mimeType: mime,
originalFilename: file.name || null,
sizeBytes: buf.byteLength,
kind: kindFor(mime),
createdAt: new Date(),
})
.returning();
return c.json(
{
id: row.id,
url: `/api/v1/media/${row.id}`,
mime_type: row.mimeType,
kind: row.kind,
size_bytes: row.sizeBytes,
original_filename: row.originalFilename,
},
201
);
});
/**
* Streamt ein Media-File via MinIO-getObject. User-gated fremde
* Files sind 404 (nicht 403, damit IDs nicht enumerierbar sind).
*/
r.get('/:id', async (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const [row] = await dbOf()
.select()
.from(mediaFiles)
.where(and(eq(mediaFiles.id, id), eq(mediaFiles.userId, userId)))
.limit(1);
if (!row) return c.json({ error: 'not_found' }, 404);
const stream = await storageOf().getObjectStream(row.objectKey);
c.header('Content-Type', row.mimeType);
c.header('Content-Length', String(row.sizeBytes));
c.header('Cache-Control', 'private, max-age=31536000, immutable');
return new Response(stream as unknown as ReadableStream, {
status: 200,
headers: c.res.headers,
});
});
/** Listet alle Media-Files des Users — nützlich fürs UI später. */
r.get('/', async (c) => {
const userId = c.get('userId');
const rows = await dbOf().select().from(mediaFiles).where(eq(mediaFiles.userId, userId));
return c.json({
files: rows.map((r) => ({
id: r.id,
url: `/api/v1/media/${r.id}`,
mime_type: r.mimeType,
kind: r.kind,
size_bytes: r.sizeBytes,
original_filename: r.originalFilename,
created_at: r.createdAt.toISOString(),
})),
total: rows.length,
});
});
return r;
}

View file

@ -5,7 +5,6 @@ import {
CardsCreateInputSchema,
CardsSearchInputSchema,
cardContentHash,
maskRegionCount,
subIndexCount,
subIndexCountForCloze,
} from '@cards/domain';
@ -18,7 +17,7 @@ import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { ulid } from '../lib/ulid.ts';
import { searchUserCards } from '../lib/search.ts';
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://cardecky.mana.how';
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://wordeck.com';
const APP_VERSION = process.env.CARDS_API_VERSION ?? '0.0.0';
export type ToolsDeps = { db?: CardsDb };
@ -60,8 +59,7 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }>
if (deck.userId !== userId) return c.json({ error: 'deck_not_owned' }, 403);
// Text-abhängige Sub-Index-Counts identisch zum REST-Pfad
// (cards.ts POST). Cloze ohne Cluster + Image-Occlusion
// ohne Mask-Regions werden 422.
// (cards.ts POST). Cloze ohne Cluster wird 422.
let count: number;
if (parsed.data.type === 'cloze') {
count = subIndexCountForCloze(parsed.data.fields.text ?? '');
@ -71,17 +69,6 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }>
422
);
}
} else if (parsed.data.type === 'image-occlusion') {
count = maskRegionCount(parsed.data.fields.mask_regions ?? '');
if (count === 0) {
return c.json(
{
error: 'invalid_input',
issues: ['image-occlusion.mask_regions must be JSON array with >=1 region'],
},
422
);
}
} else {
count = subIndexCount(parsed.data.type);
}
@ -101,7 +88,6 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }>
userId,
type: parsed.data.type,
fields: parsed.data.fields,
mediaRefs: parsed.data.media_refs ?? [],
contentHash,
createdAt: now,
updatedAt: now,
@ -118,7 +104,6 @@ export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }>
user_id: row.userId,
type: row.type,
fields: row.fields,
media_refs: row.mediaRefs ?? [],
content_hash: row.contentHash,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),

View file

@ -1,92 +0,0 @@
/**
* Object-Storage über MinIO (S3-API-kompatibel).
*
* Lokal: Container `cards-minio` (siehe infrastructure/docker-compose.yml)
* auf 9100/9101 Plattform-MinIO bleibt auf 9000/9001 ungestört.
*
* Produktiv (Phase 10): entweder eigener MinIO auf dem Mac Mini mit
* separatem Bucket, oder gegen das Plattform-MinIO mit eigenem Bucket
* `cards-media`. Konfiguration via env, kein Code-Pfad muss umgebogen
* werden.
*/
import * as Minio from 'minio';
let cached: StorageService | null = null;
export class StorageService {
readonly client: Minio.Client;
readonly bucket: string;
private bucketReady = false;
constructor() {
this.client = new Minio.Client({
endPoint: process.env.CARDS_S3_ENDPOINT ?? 'localhost',
port: Number(process.env.CARDS_S3_PORT ?? 9100),
useSSL: process.env.CARDS_S3_USE_SSL === 'true',
accessKey: process.env.CARDS_S3_ACCESS_KEY ?? 'cardsadmin',
secretKey: process.env.CARDS_S3_SECRET_KEY ?? 'cardsadmin',
});
this.bucket = process.env.CARDS_S3_BUCKET ?? 'cards-media';
}
/** Idempotenter Bucket-Init. Wird einmal pro Process-Lifetime gerufen. */
async ensureBucket(): Promise<void> {
if (this.bucketReady) return;
const exists = await this.client.bucketExists(this.bucket).catch(() => false);
if (!exists) {
await this.client.makeBucket(this.bucket);
}
this.bucketReady = true;
}
async putObject(
key: string,
body: Buffer | Uint8Array,
contentType: string
): Promise<void> {
await this.ensureBucket();
await this.client.putObject(this.bucket, key, Buffer.from(body), body.byteLength, {
'Content-Type': contentType,
});
}
async getObjectStream(key: string): Promise<NodeJS.ReadableStream> {
await this.ensureBucket();
return this.client.getObject(this.bucket, key);
}
async statObject(key: string): Promise<{ size: number; contentType: string }> {
await this.ensureBucket();
const stat = await this.client.statObject(this.bucket, key);
return {
size: stat.size,
contentType: stat.metaData?.['content-type'] ?? 'application/octet-stream',
};
}
async removeObject(key: string): Promise<void> {
await this.ensureBucket();
await this.client.removeObject(this.bucket, key);
}
async removeObjectsByPrefix(prefix: string): Promise<number> {
await this.ensureBucket();
const objectsStream = this.client.listObjectsV2(this.bucket, prefix, true);
const keys: string[] = [];
for await (const obj of objectsStream) {
if (obj.name) keys.push(obj.name);
}
if (keys.length > 0) await this.client.removeObjects(this.bucket, keys);
return keys.length;
}
}
export function getStorage(): StorageService {
if (!cached) cached = new StorageService();
return cached;
}
export function resetStorageForTests(): void {
cached = null;
}

View file

@ -15,7 +15,7 @@ export type HandlerResult = {
resulting_id: string;
};
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://cardecky.mana.how';
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://wordeck.com';
/**
* Legt eine basic-Karte mit (front,back) im Inbox-Deck an, inkl.
@ -38,7 +38,6 @@ async function persistCardInInbox(
userId,
type: 'basic',
fields: { front, back },
mediaRefs: [],
createdAt: now,
updatedAt: now,
});

View file

@ -1,53 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { mediaRouter } from '../src/routes/media.ts';
import type { CardsDb } from '../src/db/connection.ts';
/**
* Auth-Gate-Tests für die Media-Routen ohne echte DB. Wir prüfen, dass
* der authMiddleware-Pfad ehrt und Validation-Errors konsistent sind.
* Ein echter MinIO-Roundtrip bleibt dem manuellen E2E-Smoke vorbehalten,
* weil sql.js + JSZip + MinIO-SDK in Vitest zu viel Mock-Overhead wäre.
*/
function buildApp() {
const app = new Hono();
const stub = {} as CardsDb;
app.route('/api/v1/media', mediaRouter({ db: stub }));
return { app };
}
describe('mediaRouter — auth-gate', () => {
it('GET ohne X-User-Id ist 401', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/media');
expect(res.status).toBe(401);
});
it('GET /:id ohne X-User-Id ist 401', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/media/abc');
expect(res.status).toBe(401);
});
it('POST /upload ohne X-User-Id ist 401', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/media/upload', {
method: 'POST',
});
expect(res.status).toBe(401);
});
});
describe('mediaRouter — Input-Validation', () => {
it('POST /upload ohne multipart-Body ist 400', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/media/upload', {
method: 'POST',
headers: { 'X-User-Id': 'u-1' },
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('expected_multipart');
});
});

View file

@ -1,128 +1,38 @@
/**
* 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.
* Schreibt gegen die wordeck-api HTTP-Endpoints keine Dexie, keine
* lokalen Stores. Anki-Decks werden 1:1 in Decks gemappt (Anki-`::`
* zu ` / ` flacht die Hierarchie aus, wie im Original). Karten werden
* mit sanitisiertem Markdown angelegt.
*
* Phase 9k: Media-Upload via MinIO. Bilder + Audio werden vor den
* Karten in den Cards-Bucket geladen, der Sanitize-Pfad ersetzt
* Anki-Filenames durch echte Media-URLs (`/api/v1/media/<id>`).
* Wordeck-Rebrand (2026-05-17): Media-Refs (Bilder + Audio) werden
* beim Import komplett gedroppt. Wordeck ist text-only der
* Sanitize-Pfad strippt `<img>` und `[sound:…]` ersatzlos.
*
* Phase 9j Re-Import-Dedupe: content_hash-Set wird vor dem Loop
* geladen, Duplikate werden gezählt und übersprungen.
*/
import JSZip from 'jszip';
import { cardContentHash } from '@cards/domain';
import { createDeck } from '$lib/api/decks.ts';
import { createCard, listCardHashes } from '$lib/api/cards.ts';
import { uploadMedia } from '$lib/api/media.ts';
import { sanitizeAnkiHtml, type ParsedAnki } from './parse.ts';
export interface ImportResult {
decksCreated: number;
cardsCreated: number;
cardsSkippedDuplicate: number;
mediaUploaded: number;
mediaFailed: number;
failed: number;
failures: string[];
}
export interface ImportProgress {
stage: 'media' | 'decks' | 'cards' | 'done';
stage: 'decks' | 'cards' | 'done';
current: number;
total: number;
}
const MEDIA_CONCURRENCY = 4;
const IMG_RE = /<img\b[^>]*\bsrc=["']([^"']+)["']/gi;
const SOUND_RE = /\[sound:([^\]]+)\]/g;
function collectMediaRefs(parsed: ParsedAnki): Set<string> {
const refs = new Set<string>();
for (const card of parsed.cards) {
for (const value of Object.values(card.fields)) {
let m: RegExpExecArray | null;
IMG_RE.lastIndex = 0;
while ((m = IMG_RE.exec(value))) refs.add(m[1]);
SOUND_RE.lastIndex = 0;
while ((m = SOUND_RE.exec(value))) refs.add(m[1]);
}
}
return refs;
}
function guessMime(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
oga: 'audio/ogg',
wav: 'audio/wav',
m4a: 'audio/mp4',
mp4: 'video/mp4',
webm: 'video/webm',
};
return map[ext] ?? 'application/octet-stream';
}
async function uploadAllMedia(
parsed: ParsedAnki,
onProgress?: (current: number, total: number) => void
): Promise<{ urlByFilename: Map<string, string>; uploaded: number; failed: number }> {
const referenced = [...collectMediaRefs(parsed)].filter((f) => parsed.mediaByFilename.has(f));
const urlByFilename = new Map<string, string>();
let uploaded = 0;
let failed = 0;
let done = 0;
if (referenced.length === 0) {
onProgress?.(0, 0);
return { urlByFilename, uploaded, failed };
}
let nextIdx = 0;
async function worker() {
while (true) {
const idx = nextIdx++;
if (idx >= referenced.length) return;
const filename = referenced[idx];
const entry = parsed.mediaByFilename.get(filename);
if (!entry) {
failed++;
done++;
onProgress?.(done, referenced.length);
continue;
}
try {
const blob = await (entry as JSZip.JSZipObject).async('blob');
const file = new File([blob], filename, { type: guessMime(filename) });
const result = await uploadMedia(file);
urlByFilename.set(filename, result.url);
uploaded++;
} catch (e) {
console.warn(`[anki-import] media upload failed for ${filename}:`, e);
failed++;
}
done++;
onProgress?.(done, referenced.length);
}
}
await Promise.all(Array.from({ length: MEDIA_CONCURRENCY }, () => worker()));
return { urlByFilename, uploaded, failed };
}
export async function importParsedAnki(
parsed: ParsedAnki,
opts: { onProgress?: (p: ImportProgress) => void } = {}
@ -131,8 +41,6 @@ export async function importParsedAnki(
decksCreated: 0,
cardsCreated: 0,
cardsSkippedDuplicate: 0,
mediaUploaded: 0,
mediaFailed: 0,
failed: 0,
failures: [],
};
@ -146,17 +54,7 @@ export async function importParsedAnki(
// Dedupe bleibt aus (älterer Server o.ä.).
}
// 1) Media — vor den Karten uploaden, damit der Sanitize-Pfad echte
// URLs einsetzen kann. Files, die nicht im Anki-Manifest stehen,
// werden gedroppt; Upload-Fehler werden gezählt + im Card-Field
// gedroppt (statt 404-URL).
const { urlByFilename, uploaded, failed } = await uploadAllMedia(parsed, (current, total) => {
opts.onProgress?.({ stage: 'media', current, total });
});
result.mediaUploaded = uploaded;
result.mediaFailed = failed;
// 2) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen.
// 1) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen.
const ankiIdToDeckId = new Map<string, string>();
let deckIdx = 0;
for (const ankiDeck of parsed.decks) {
@ -186,14 +84,14 @@ export async function importParsedAnki(
}
};
// 3) Cards — sanitize mit URL-Map, content_hash-Dedupe, Insert.
// 2) Cards — sanitize strippt img + sound, content_hash-Dedupe, Insert.
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, urlByFilename);
cleanFields[key] = sanitizeAnkiHtml(value);
}
const hash = await cardContentHash({ type: card.type, fields: cleanFields });

View file

@ -218,36 +218,21 @@ function mapNoteToCard(
* wird gedroppt das passiert z.B. wenn der Media-Upload für diese
* Datei fehlschlägt.
*
* `<img>` Markdown `![alt](url)`. `[sound:foo.mp3]` HTML
* `<audio src="url" controls>` (Markdown hat keine native Audio-Syntax,
* aber unser Renderer sanitized HTML mit DOMPurify und lässt `<audio>`
* durch).
* Wordeck-Rebrand (2026-05-17): text-only. `<img>` und `[sound:…]`
* werden ersatzlos gestrippt kein Media-Upload mehr.
*/
export function sanitizeAnkiHtml(
html: string,
mediaUrlByFilename: Map<string, string> = new Map()
): string {
const imgReplaced = html.replace(
/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
(_, src: string) => {
const url = mediaUrlByFilename.get(src);
return url ? `![${src}](${url})` : '';
}
);
const soundReplaced = imgReplaced.replace(/\[sound:([^\]]+)\]/g, (_, name: string) => {
const url = mediaUrlByFilename.get(name);
return url ? `<audio controls preload="metadata" src="${url}"></audio>` : '';
});
export function sanitizeAnkiHtml(html: string): string {
const imgStripped = html.replace(/<img\b[^>]*>/gi, '');
const soundStripped = imgStripped.replace(/\[sound:[^\]]+\]/g, '');
return soundReplaced
return soundStripped
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/?(?:b|strong)>/gi, '**')
.replace(/<\/?(?:i|em)>/gi, '*')
.replace(/<\/?p>/gi, '\n')
.replace(/<\/?div>/gi, '\n')
// Drop remaining HTML tags except the ones we just emitted
// (audio/video/source) — die müssen den Renderer überleben.
.replace(/<(?!\/?(?:audio|video|source)\b)[^>]+>/gi, '')
// Drop remaining HTML tags — Wordeck ist text-only.
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')

View file

@ -1,59 +0,0 @@
import { API_BASE, ApiError } from './client.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
export interface MediaUploadResult {
id: string;
url: string;
mime_type: string;
kind: 'image' | 'audio' | 'video' | 'other';
size_bytes: number;
original_filename: string | null;
}
/**
* Lädt ein einzelnes File via multipart/form-data hoch. Anders als der
* Standard-API-Helper geht das nicht über `Content-Type: application/json`,
* deshalb hier ein eigener Pfad mit FormData + manuellem fetch.
*/
export async function uploadMedia(file: File | Blob, filename?: string): Promise<MediaUploadResult> {
const form = new FormData();
const wrapped = file instanceof File ? file : new File([file], filename ?? 'upload.bin');
form.append('file', wrapped);
await devUser.ensureFreshToken();
const buildHeaders = (): Record<string, string> => {
const h: Record<string, string> = {};
if (devUser.token) h['Authorization'] = `Bearer ${devUser.token}`;
else if (devUser.stubId) h['X-User-Id'] = devUser.stubId;
return h;
};
let res = await fetch(`${API_BASE}/api/v1/media/upload`, {
method: 'POST',
body: form,
headers: buildHeaders(),
});
if (res.status === 401 && devUser.token) {
const refreshed = await devUser.tryRefresh();
if (refreshed) {
res = await fetch(`${API_BASE}/api/v1/media/upload`, {
method: 'POST',
body: form,
headers: buildHeaders(),
});
}
}
if (!res.ok) {
let body: unknown = null;
try {
body = await res.json();
} catch {
body = await res.text().catch(() => null);
}
throw new ApiError(res.status, body, `media upload failed: ${res.status}`);
}
return res.json();
}

View file

@ -172,9 +172,7 @@
</div>
{:else if stage === 'importing'}
<div class="py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
{#if progress.stage === 'media'}
{t('import.stage_media', { current: progress.current, total: progress.total })}
{:else if progress.stage === 'decks'}
{#if progress.stage === 'decks'}
{t('import.stage_decks', { current: progress.current, total: progress.total })}
{:else if progress.stage === 'cards'}
{t('import.stage_cards', { current: progress.current, total: progress.total })}
@ -206,14 +204,6 @@
{t('import.done_dupes', { n: result.cardsSkippedDuplicate })}
</div>
{/if}
{#if result.mediaUploaded > 0 || result.mediaFailed > 0}
<div class="text-[hsl(var(--color-muted-foreground))]">
{t('import.done_media', {
uploaded: result.mediaUploaded,
failed: result.mediaFailed,
})}
</div>
{/if}
{#if result.failed > 0}
<details class="text-[hsl(var(--color-error))]">
<summary class="cursor-pointer">{t('import.done_failures', { n: result.failed })}</summary>

View file

@ -1,98 +0,0 @@
<script lang="ts">
import { API_BASE } from '$lib/api/client.ts';
let {
audioRef,
frontText,
}: {
audioRef: string;
frontText?: string;
} = $props();
let audioEl = $state<HTMLAudioElement | null>(null);
let playing = $state(false);
const audioUrl = $derived(`${API_BASE}/api/v1/media/${audioRef}`);
function toggle() {
if (!audioEl) return;
if (playing) {
audioEl.pause();
} else {
audioEl.play();
}
}
</script>
<div class="audio-front">
<!-- svelte-ignore a11y_media_has_caption -->
<audio
bind:this={audioEl}
src={audioUrl}
preload="metadata"
onplay={() => (playing = true)}
onpause={() => (playing = false)}
onended={() => (playing = false)}
></audio>
<button class="play-btn" onclick={toggle} aria-label={playing ? 'Pause' : 'Abspielen'}>
{#if playing}
<svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28" aria-hidden="true">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28" aria-hidden="true">
<polygon points="5,3 20,12 5,21" />
</svg>
{/if}
</button>
{#if frontText}
<p class="hint">{frontText}</p>
{/if}
</div>
<style>
.audio-front {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: 1.5rem 0;
width: 100%;
}
.play-btn {
width: 5rem;
height: 5rem;
border-radius: 50%;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px hsl(var(--color-primary) / 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.play-btn:hover {
transform: scale(1.06);
box-shadow: 0 6px 20px hsl(var(--color-primary) / 0.45);
}
.play-btn:active {
transform: scale(0.96);
}
.hint {
margin: 0;
font-size: 1rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
line-height: 1.5;
}
</style>

View file

@ -1,143 +0,0 @@
<script lang="ts">
import { uploadMedia } from '$lib/api/media.ts';
import { t } from '$lib/i18n/index.svelte.ts';
let { mediaRef = $bindable('') }: { mediaRef: string } = $props();
let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
let uploadError = $state<string | null>(null);
let uploadedName = $state('');
async function handleFile(file: File) {
uploading = true;
uploadError = null;
try {
const result = await uploadMedia(file);
mediaRef = result.id;
uploadedName = file.name;
} catch (e) {
uploadError = e instanceof Error ? e.message : '?';
} finally {
uploading = false;
}
}
function onPick(e: Event) {
const f = (e.currentTarget as HTMLInputElement).files?.[0];
if (f) handleFile(f);
(e.currentTarget as HTMLInputElement).value = '';
}
</script>
<div class="upload-field">
{#if uploading}
<div class="status uploading">{t('card_new.audio_uploading')}</div>
{:else if mediaRef}
<div class="status done">
<span class="filename">{uploadedName || mediaRef}</span>
<button type="button" class="replace-btn" onclick={() => fileInput?.click()}>
{t('card_new.audio_replace')}
</button>
</div>
{:else}
<button type="button" class="upload-btn" onclick={() => fileInput?.click()}>
{t('card_new.audio_upload_btn')}
</button>
{/if}
{#if uploadError}
<p class="error">{t('card_new.audio_upload_failed', { msg: uploadError })}</p>
{/if}
<input
bind:this={fileInput}
type="file"
accept="audio/*,.mp3,.ogg,.wav,.m4a,.webm,.aac"
class="hidden"
onchange={onPick}
/>
</div>
<style>
.upload-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upload-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border: 2px dashed hsl(var(--color-border));
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
width: 100%;
justify-content: center;
}
.upload-btn:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-foreground));
}
.status {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
}
.uploading {
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-border) / 0.3);
}
.done {
background: hsl(var(--color-success) / 0.08);
border: 1px solid hsl(var(--color-success) / 0.3);
color: hsl(var(--color-foreground));
}
.filename {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
}
.replace-btn {
flex-shrink: 0;
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.replace-btn:hover {
color: hsl(var(--color-foreground));
}
.error {
font-size: 0.8125rem;
color: hsl(var(--color-error));
margin: 0;
}
.hidden {
display: none;
}
</style>

View file

@ -56,8 +56,6 @@
switch (card.type) {
case 'cloze':
return f.text ?? '';
case 'image-occlusion':
return '🖼 ' + (f.image_ref?.slice(0, 16) ?? '');
default:
return f.front ?? '';
}

View file

@ -1,217 +0,0 @@
<!--
Image-Occlusion-Editor: Bild auswählen, mit Maus rechteckige Masken
auf das Bild zeichnen, jede wird zu einem eigenen Review.
Modell: alle Coordinaten in 0..1 relativ zum Bild — der Renderer
skaliert auf die tatsächliche Display-Größe. So überleben Masken
auch Browser-Resizing und Mobile-Display.
-->
<script lang="ts">
import { type MaskRegion, parseMaskRegions } from '@cards/domain';
import { uploadMedia } from '$lib/api/media.ts';
import { API_BASE } from '$lib/api/client.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
let {
imageRef = $bindable(''),
maskRegionsJson = $bindable('[]'),
}: {
imageRef: string;
maskRegionsJson: string;
} = $props();
let imgEl: HTMLImageElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
let uploading = $state(false);
let dragStart = $state<{ x: number; y: number } | null>(null);
let dragCurrent = $state<{ x: number; y: number } | null>(null);
const masks = $derived(parseMaskRegions(maskRegionsJson));
const imageUrl = $derived(imageRef ? `${API_BASE}/api/v1/media/${imageRef}` : '');
async function onFile(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
uploading = true;
try {
const r = await uploadMedia(file);
if (r.kind !== 'image') {
toasts.error(t('image_occlusion.not_an_image'));
return;
}
imageRef = r.id;
maskRegionsJson = '[]';
} catch (err) {
toasts.error(`${apiErrorMessage(err)}`);
} finally {
uploading = false;
input.value = '';
}
}
function rel(e: MouseEvent | PointerEvent): { x: number; y: number } | null {
if (!containerEl) return null;
const rect = containerEl.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
}
function onPointerDown(e: PointerEvent) {
if (!imageRef) return;
const p = rel(e);
if (!p) return;
dragStart = p;
dragCurrent = p;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragStart) return;
dragCurrent = rel(e);
}
function onPointerUp(e: PointerEvent) {
if (!dragStart || !dragCurrent) {
dragStart = null;
dragCurrent = null;
return;
}
const x = Math.min(dragStart.x, dragCurrent.x);
const y = Math.min(dragStart.y, dragCurrent.y);
const w = Math.abs(dragCurrent.x - dragStart.x);
const h = Math.abs(dragCurrent.y - dragStart.y);
// Ignoriere zu kleine Dragger (Klick statt Drag).
if (w >= 0.02 && h >= 0.02) {
const id = `m${Date.now().toString(36)}`;
const next: MaskRegion = { id, x, y, w, h };
maskRegionsJson = JSON.stringify([...masks, next]);
}
dragStart = null;
dragCurrent = null;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}
function onPointerCancel(e: PointerEvent) {
dragStart = null;
dragCurrent = null;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}
function deleteMask(id: string) {
maskRegionsJson = JSON.stringify(masks.filter((m) => m.id !== id));
}
function setLabel(id: string, label: string) {
maskRegionsJson = JSON.stringify(
masks.map((m) => (m.id === id ? { ...m, label: label || undefined } : m))
);
}
const dragRect = $derived.by(() => {
if (!dragStart || !dragCurrent) return null;
return {
x: Math.min(dragStart.x, dragCurrent.x),
y: Math.min(dragStart.y, dragCurrent.y),
w: Math.abs(dragCurrent.x - dragStart.x),
h: Math.abs(dragCurrent.y - dragStart.y),
};
});
</script>
<div class="space-y-3">
<label class="block text-sm">
<span class="font-medium">{t('image_occlusion.image_label')}</span>
<input
type="file"
accept="image/*"
class="mt-1 block text-xs"
disabled={uploading}
onchange={onFile}
/>
</label>
{#if uploading}
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('image_occlusion.uploading')}</p>
{/if}
{#if imageRef}
<div
bind:this={containerEl}
class="relative inline-block max-w-full select-none touch-none"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
role="application"
aria-label={t('image_occlusion.canvas_aria')}
>
<img
bind:this={imgEl}
src={imageUrl}
alt=""
class="block max-w-full"
draggable="false"
/>
<svg
class="absolute inset-0 h-full w-full pointer-events-none"
viewBox="0 0 1 1"
preserveAspectRatio="none"
>
{#each masks as m (m.id)}
<rect
x={m.x}
y={m.y}
width={m.w}
height={m.h}
fill="rgba(255,180,0,0.6)"
stroke="rgba(255,140,0,1)"
stroke-width="0.005"
/>
{/each}
{#if dragRect}
<rect
x={dragRect.x}
y={dragRect.y}
width={dragRect.w}
height={dragRect.h}
fill="rgba(0,140,255,0.4)"
stroke="rgba(0,100,200,1)"
stroke-width="0.005"
stroke-dasharray="0.01,0.005"
/>
{/if}
</svg>
</div>
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('image_occlusion.draw_hint')}</p>
{#if masks.length > 0}
<ul class="space-y-2 text-sm">
{#each masks as m, i (m.id)}
<li class="flex items-center gap-3 rounded border border-[hsl(var(--color-border))] px-3 py-2">
<span class="text-xs text-[hsl(var(--color-muted-foreground))] tabular-nums">{i + 1}</span>
<input
type="text"
placeholder={t('image_occlusion.label_placeholder')}
value={m.label ?? ''}
oninput={(e) => setLabel(m.id, (e.currentTarget as HTMLInputElement).value)}
class="flex-1 rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-2 py-1 text-sm"
/>
<button
type="button"
onclick={() => deleteMask(m.id)}
class="text-xs text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-error))]"
aria-label={t('image_occlusion.delete_mask')}
>
×
</button>
</li>
{/each}
</ul>
{/if}
{/if}
</div>

View file

@ -1,72 +0,0 @@
<!--
Image-Occlusion-Render im Study-View. Zeigt das Bild und überlagert
es mit den Mask-Regionen. Aktive Maske ist immer opake (Prompt:
Antwort versteckt; Reveal: aktive ist transparent grün als Bestätigung).
Andere Masken bleiben dezent durchsichtig — der User sieht sie als
Hinweis darauf, was es noch zu lernen gibt.
-->
<script lang="ts">
import { parseMaskRegions } from '@cards/domain';
import { API_BASE } from '$lib/api/client.ts';
let {
imageRef,
maskRegionsJson,
activeMaskId,
revealed,
}: {
imageRef: string;
maskRegionsJson: string;
activeMaskId: string | null;
revealed: boolean;
} = $props();
const masks = $derived(parseMaskRegions(maskRegionsJson));
const imageUrl = $derived(`${API_BASE}/api/v1/media/${imageRef}`);
function fillFor(maskId: string): string {
const isActive = maskId === activeMaskId;
if (isActive) {
return revealed ? 'rgba(34,197,94,0.55)' : 'rgba(20,20,30,0.95)';
}
// Andere Masken: leicht sichtbar als Lern-Hinweis.
return 'rgba(255,180,0,0.18)';
}
</script>
<div class="relative inline-block max-w-full">
<img src={imageUrl} alt="" class="block max-w-full" draggable="false" />
<svg
class="pointer-events-none absolute inset-0 h-full w-full"
viewBox="0 0 1 1"
preserveAspectRatio="none"
>
{#each masks as m (m.id)}
<rect
x={m.x}
y={m.y}
width={m.w}
height={m.h}
fill={fillFor(m.id)}
stroke={m.id === activeMaskId ? 'rgba(20,20,30,1)' : 'rgba(255,140,0,0.4)'}
stroke-width="0.004"
/>
{#if m.id === activeMaskId && revealed && m.label}
<text
x={m.x + m.w / 2}
y={m.y + m.h / 2}
text-anchor="middle"
dominant-baseline="middle"
fill="white"
font-size="0.04"
font-family="system-ui, sans-serif"
stroke="rgba(0,0,0,0.8)"
stroke-width="0.002"
paint-order="stroke"
>
{m.label}
</text>
{/if}
{/each}
</svg>
</div>

View file

@ -4,7 +4,6 @@
import { page } from '$app/state';
import {
extractClusterIds,
maskRegionCount,
renderClozePrompt,
type Card,
type CardType,
@ -15,9 +14,7 @@
import { renderMarkdown } from '$lib/markdown.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte';
import AudioUploadField from '$lib/components/AudioUploadField.svelte';
let card = $state<Card | null>(null);
let cardType = $state<CardType>('basic');
@ -25,11 +22,8 @@
let back = $state('');
let text = $state('');
let extra = $state('');
let imageRef = $state('');
let maskRegionsJson = $state('[]');
let answer = $state('');
let aliases = $state('');
let audioFileRef = $state('');
let mcOptions = $state(['', '', '', '']);
let mcCorrectIdx = $state(0);
let mcExplanation = $state('');
@ -61,9 +55,6 @@
if (c.type === 'cloze') {
text = fields.text ?? '';
extra = fields.extra ?? '';
} else if (c.type === 'image-occlusion') {
imageRef = fields.image_ref ?? '';
maskRegionsJson = fields.mask_regions ?? '[]';
} else if (c.type === 'multiple-choice') {
front = fields.front ?? '';
const answer = fields.answer ?? '';
@ -76,9 +67,6 @@
front = fields.front ?? '';
answer = fields.answer ?? '';
aliases = fields.aliases ?? '';
} else if (c.type === 'audio-front') {
audioFileRef = fields.audio_ref ?? '';
back = fields.back ?? '';
} else {
front = fields.front ?? '';
back = fields.back ?? '';
@ -90,18 +78,14 @@
}
});
const maskCount = $derived(maskRegionCount(maskRegionsJson));
const canSave = $derived.by(() => {
if (saving) return false;
if (cardType === 'cloze') return text.trim().length > 0 && clusterIds.length > 0;
if (cardType === 'image-occlusion') return imageRef.length > 0 && maskCount > 0;
if (cardType === 'multiple-choice') {
const filledCount = mcOptions.filter((o) => o.trim()).length;
return front.trim().length > 0 && mcOptions[mcCorrectIdx].trim().length > 0 && filledCount >= 2;
}
if (cardType === 'typing') return front.trim().length > 0 && answer.trim().length > 0;
if (cardType === 'audio-front') return audioFileRef.trim().length > 0 && back.trim().length > 0;
return front.trim().length > 0 && back.trim().length > 0;
});
@ -115,8 +99,6 @@
fields = extra.trim()
? { text: text.trim(), extra: extra.trim() }
: { text: text.trim() };
} else if (cardType === 'image-occlusion') {
fields = { image_ref: imageRef, mask_regions: maskRegionsJson };
} else if (cardType === 'multiple-choice') {
const distractors = mcOptions
.filter((_, i) => i !== mcCorrectIdx)
@ -128,8 +110,6 @@
} else if (cardType === 'typing') {
fields = { front: front.trim(), answer: answer.trim() };
if (aliases.trim()) fields.aliases = aliases.trim();
} else if (cardType === 'audio-front') {
fields = { audio_ref: audioFileRef.trim(), back: back.trim() };
} else {
fields = { front: front.trim(), back: back.trim() };
}
@ -178,9 +158,7 @@
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{t('card_edit.type_locked_help')}</p>
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
{#if cardType === 'image-occlusion'}
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
{:else if cardType === 'multiple-choice'}
{#if cardType === 'multiple-choice'}
<MultipleChoiceCardForm bind:front bind:mcOptions bind:mcCorrectIdx bind:mcExplanation />
{:else if cardType === 'cloze'}
<div>
@ -257,23 +235,6 @@
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{t('card_new.typing_aliases_hint')}</p>
</label>
{:else if cardType === 'audio-front'}
<label class="block">
<span class="text-sm font-medium">{t('card_new.audio_ref_label')}</span>
<div class="mt-1">
<AudioUploadField bind:mediaRef={audioFileRef} />
</div>
</label>
<label class="block">
<span class="text-sm font-medium">{t('card_new.back_audio_label')}</span>
<textarea
bind:value={back}
required
rows="6"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>

View file

@ -4,22 +4,18 @@
import { page } from '$app/state';
import {
extractClusterIds,
maskRegionCount,
renderClozePrompt,
type CardType,
} from '@cards/domain';
import { createCard } from '$lib/api/cards.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { listDecks, getDeck } from '$lib/api/decks.ts';
import { API_BASE } from '$lib/api/client.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { renderMarkdown } from '$lib/markdown.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
import ClozeCardForm from '$lib/components/ClozeCardForm.svelte';
import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte';
import AudioUploadField from '$lib/components/AudioUploadField.svelte';
import CardSurface from '$lib/components/CardSurface.svelte';
type DeckLite = { id: string; name: string };
@ -31,11 +27,8 @@
let back = $state('');
let text = $state('');
let extra = $state('');
let imageRef = $state('');
let maskRegionsJson = $state('[]');
let answer = $state('');
let aliases = $state('');
let audioFileRef = $state('');
let saving = $state(false);
let previewFlipped = $state(false);
@ -64,10 +57,8 @@
basic: 'Klassische Karteikarte: Vorderseite → Rückseite.',
'basic-reverse': 'Wie Basic, aber beide Richtungen werden abgefragt (2 Reviews).',
cloze: 'Lückentext — jeder {{cN::}}-Cluster wird einzeln abgefragt.',
'image-occlusion': 'Bild mit N ausgeblendeten Bereichen — 1 Review pro Maske.',
typing: 'Du tippst die Antwort; Fuzzy-Match erlaubt kleine Tippfehler.',
'multiple-choice': '4 Antwortoptionen, KI generiert fehlende Distractors beim Lernen.',
'audio-front': 'Audioclip als Vorderseite, Text als Antwort.',
};
onMount(async () => {
@ -92,18 +83,14 @@
}
});
const maskCount = $derived(maskRegionCount(maskRegionsJson));
const canSave = $derived.by(() => {
if (saving || !deckId) return false;
if (cardType === 'cloze') return text.trim().length > 0 && clusterIds.length > 0;
if (cardType === 'image-occlusion') return imageRef.length > 0 && maskCount > 0;
if (cardType === 'typing') return front.trim().length > 0 && answer.trim().length > 0;
if (cardType === 'multiple-choice') {
const filledCount = mcOptions.filter((o) => o.trim()).length;
return front.trim().length > 0 && mcOptions[mcCorrectIdx].trim().length > 0 && filledCount >= 2;
}
if (cardType === 'audio-front') return audioFileRef.trim().length > 0 && back.trim().length > 0;
return front.trim().length > 0 && back.trim().length > 0;
});
@ -117,8 +104,6 @@
fields = extra.trim()
? { text: text.trim(), extra: extra.trim() }
: { text: text.trim() };
} else if (cardType === 'image-occlusion') {
fields = { image_ref: imageRef, mask_regions: maskRegionsJson };
} else if (cardType === 'typing') {
fields = { front: front.trim(), answer: answer.trim() };
if (aliases.trim()) fields.aliases = aliases.trim();
@ -130,8 +115,6 @@
fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() };
if (distractors) fields.distractor_pool = distractors;
if (mcExplanation.trim()) fields.explanation = mcExplanation.trim();
} else if (cardType === 'audio-front') {
fields = { audio_ref: audioFileRef.trim(), back: back.trim() };
} else {
fields = { front: front.trim(), back: back.trim() };
}
@ -139,17 +122,13 @@
const msg =
cardType === 'cloze'
? t('card_new.toast_cloze', { n: clusterIds.length })
: cardType === 'image-occlusion'
? t('card_new.toast_image_occlusion', { n: maskCount })
: cardType === 'typing'
? t('card_new.toast_typing')
: cardType === 'multiple-choice'
? t('card_new.toast_multiple_choice')
: cardType === 'audio-front'
? t('card_new.toast_audio_front')
: cardType === 'basic-reverse'
? t('card_new.toast_basic_reverse')
: t('card_new.toast_basic');
: cardType === 'typing'
? t('card_new.toast_typing')
: cardType === 'multiple-choice'
? t('card_new.toast_multiple_choice')
: cardType === 'basic-reverse'
? t('card_new.toast_basic_reverse')
: t('card_new.toast_basic');
toasts.success(msg);
goto(`/decks/${card.deck_id}`);
} catch (e) {
@ -184,10 +163,8 @@
<option value="basic">{t('card_new.type_basic')}</option>
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
<option value="cloze">{t('card_new.type_cloze')}</option>
<option value="image-occlusion">{t('card_new.type_image_occlusion')}</option>
<option value="typing">{t('card_new.type_typing')}</option>
<option value="multiple-choice">{t('card_new.type_multiple_choice')}</option>
<option value="audio-front">{t('card_new.type_audio_front')}</option>
</select>
</label>
</div>
@ -196,10 +173,7 @@
<!-- Type-specific fields -->
<section class="form-section">
{#if cardType === 'image-occlusion'}
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
{:else if cardType === 'cloze'}
{#if cardType === 'cloze'}
<ClozeCardForm bind:text bind:extra {clusterIds} />
{:else if cardType === 'multiple-choice'}
@ -239,22 +213,6 @@
<span class="field-hint">{t('card_new.typing_aliases_hint')}</span>
</label>
{:else if cardType === 'audio-front'}
<label class="field">
<span class="field-label">{t('card_new.audio_ref_label')}</span>
<AudioUploadField bind:mediaRef={audioFileRef} />
</label>
<label class="field">
<span class="field-label">{t('card_new.back_audio_label')}</span>
<textarea
bind:value={back}
required
rows="5"
placeholder={t('card_new.back_placeholder')}
class="input mono"
></textarea>
</label>
{:else}
<!-- basic / basic-reverse -->
<div class="grid-2">
@ -299,53 +257,7 @@
<CardSurface size="hero" raised class="preview-card-surface">
{#snippet children()}
<div class="preview-inner">
{#if cardType === 'image-occlusion'}
{#if imageRef}
<img
src="{API_BASE}/api/v1/media/{imageRef}"
alt="Occlusion-Bild"
class="preview-occlusion-img"
/>
{:else}
<div class="preview-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="m21 15-5-5L5 21" />
</svg>
<span>Noch kein Bild gewählt</span>
</div>
{/if}
{:else if cardType === 'audio-front'}
<div class="preview-audio">
<div class="preview-audio-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
{#if audioFileRef}
<span class="preview-audio-ref">{audioFileRef}</span>
{:else}
<span class="preview-empty-hint">Audio-Datei</span>
{/if}
</div>
{#if previewFlipped}
<div class="preview-divider"></div>
<div class="preview-prose">
{#if back.trim()}
{@html backHtml}
{:else}
<span class="preview-empty-hint">Antworttext…</span>
{/if}
</div>
{:else}
<button type="button" class="preview-reveal-btn" onclick={() => { previewFlipped = true; }}>
Lösung zeigen
</button>
{/if}
{:else if cardType === 'cloze'}
{#if cardType === 'cloze'}
<div class="preview-prose">
{#if text.trim()}
{@html clozePreviewHtml}
@ -435,7 +347,7 @@
{/snippet}
</CardSurface>
{#if cardType === 'basic' || cardType === 'basic-reverse' || cardType === 'audio-front'}
{#if cardType === 'basic' || cardType === 'basic-reverse'}
<p class="preview-flip-hint">Klicke „Lösung zeigen" um zu flippen</p>
{:else if cardType === 'multiple-choice'}
<p class="preview-flip-hint">Vorschau aktualisiert sich live</p>

View file

@ -268,23 +268,17 @@
{/if}
<!-- Karteninhalt -->
{#if card.type === 'image-occlusion'}
<div class="card-image-placeholder" aria-hidden="true">
<Image size={32} weight="duotone" />
<div class="card-content">
<div class="card-side card-front md-content">
{@html md(cardFront(card))}
</div>
{:else}
<div class="card-content">
<div class="card-side card-front md-content">
{@html md(cardFront(card))}
{#if cardBack(card)}
<div class="card-divider" aria-hidden="true"></div>
<div class="card-side card-back md-content">
{@html md(cardBack(card) ?? '')}
</div>
{#if cardBack(card)}
<div class="card-divider" aria-hidden="true"></div>
<div class="card-side card-back md-content">
{@html md(cardBack(card) ?? '')}
</div>
{/if}
</div>
{/if}
{/if}
</div>
</div>
</CardSurface>
</li>

View file

@ -90,7 +90,7 @@
categoryOpen = false;
}
async function onSave(e: SubmitEvent) {
async function onSave(e: Event) {
e.preventDefault();
if (!canSave) return;
saving = true;

View file

@ -4,7 +4,6 @@
import { goto } from '$app/navigation';
import {
clusterIdForSubIndex,
maskForSubIndex,
renderClozePrompt,
renderClozeAnswer,
type Rating,
@ -16,8 +15,6 @@
import { renderMarkdown } from '$lib/markdown.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
import AudioFrontView from '$lib/components/AudioFrontView.svelte';
import TypingView from '$lib/components/TypingView.svelte';
import MultipleChoiceView from '$lib/components/MultipleChoiceView.svelte';
import CardSurface from '$lib/components/CardSurface.svelte';
@ -93,19 +90,6 @@
const promptHtml = $derived(renderMarkdown(promptMarkdown));
const answerHtml = $derived(renderMarkdown(answerMarkdown));
const isImageOcclusion = $derived(current?.card?.type === 'image-occlusion');
const imageOcclusionData = $derived.by(() => {
const c = current;
if (!c?.card || c.card.type !== 'image-occlusion') return null;
const fields = c.card.fields as Record<string, string>;
const mask = maskForSubIndex(fields.mask_regions ?? '', c.sub_index);
return {
imageRef: fields.image_ref ?? '',
maskRegionsJson: fields.mask_regions ?? '[]',
activeMaskId: mask?.id ?? null,
};
});
const isMultipleChoice = $derived(current?.card?.type === 'multiple-choice');
const multipleChoiceData = $derived.by(() => {
const c = current;
@ -129,17 +113,6 @@
};
});
const isAudioFront = $derived(current?.card?.type === 'audio-front');
const audioFrontData = $derived.by(() => {
const c = current;
if (!c?.card || c.card.type !== 'audio-front') return null;
const fields = c.card.fields as Record<string, string>;
return {
audioRef: fields.audio_ref ?? '',
frontText: fields.front || undefined,
};
});
const STATE_LABELS: Record<string, string> = {
new: 'Neu', learning: 'Lernend', review: 'Wiederholen', relearning: 'Nachlernen'
};
@ -351,14 +324,7 @@
<h2 id="study-prompt-heading" class="sr-only">
{revealed ? t('card_new.preview_label') : t('study_session.reveal')}
</h2>
{#if isImageOcclusion && imageOcclusionData}
<ImageOcclusionView
imageRef={imageOcclusionData.imageRef}
maskRegionsJson={imageOcclusionData.maskRegionsJson}
activeMaskId={imageOcclusionData.activeMaskId}
{revealed}
/>
{:else if isMultipleChoice && multipleChoiceData}
{#if isMultipleChoice && multipleChoiceData}
{#key current?.card_id}
<MultipleChoiceView
promptHtml={promptHtml}
@ -379,20 +345,13 @@
ongrade={grade}
/>
{:else}
{#if isAudioFront && audioFrontData}
<AudioFrontView
audioRef={audioFrontData.audioRef}
frontText={audioFrontData.frontText}
/>
{:else}
<div class="prose">{@html promptHtml}</div>
{/if}
<div class="prose">{@html promptHtml}</div>
{#if revealed}
<hr class="divider" />
<div class="prose answer">{@html answerHtml}</div>
{/if}
{/if}
{#if revealed && current && !isTyping && !isMultipleChoice && !isImageOcclusion}
{#if revealed && current && !isTyping && !isMultipleChoice}
<div class="fsrs-info-wrap">
<button
class="fsrs-info-btn"

View file

@ -149,23 +149,11 @@ describe('parseApkg', () => {
});
describe('sanitizeAnkiHtml', () => {
it('droppt Bilder und Audio ohne URL-Map (lossy fallback)', () => {
it('droppt Bilder und Audio ersatzlos (text-only Wordeck)', () => {
const out = sanitizeAnkiHtml('Vorne <img src="paris.jpg"> Hinten [sound:audio.mp3] fertig.');
expect(out).toBe('Vorne Hinten fertig.');
});
it('ersetzt Bilder durch Markdown wenn URL-Map gesetzt', () => {
const map = new Map([['paris.jpg', '/api/v1/media/abc']]);
const out = sanitizeAnkiHtml('Vorne <img src="paris.jpg"> hinten.', map);
expect(out).toBe('Vorne ![paris.jpg](/api/v1/media/abc) hinten.');
});
it('ersetzt [sound:…] durch <audio> wenn URL-Map gesetzt', () => {
const map = new Map([['x.mp3', '/api/v1/media/xyz']]);
const out = sanitizeAnkiHtml('Vorne [sound:x.mp3] hinten.', map);
expect(out).toContain('<audio controls preload="metadata" src="/api/v1/media/xyz">');
});
it('konvertiert Bold/Italic zu Markdown', () => {
expect(sanitizeAnkiHtml('Das <b>ist</b> <i>wichtig</i>')).toBe('Das **ist** *wichtig*');
});

View file

@ -43,25 +43,6 @@ services:
timeout: 3s
retries: 20
cards-minio:
image: minio/minio:latest
container_name: cards-minio
restart: unless-stopped
command: server /data --console-address ':9001'
environment:
MINIO_ROOT_USER: cardsadmin
MINIO_ROOT_PASSWORD: ${CARDS_S3_SECRET_KEY:?missing CARDS_S3_SECRET_KEY}
ports:
- '127.0.0.1:9210:9000'
- '127.0.0.1:9211:9001'
volumes:
- /Volumes/ManaData/cards/minio:/data
healthcheck:
test: ['CMD', 'mc', 'ready', 'local']
interval: 5s
timeout: 3s
retries: 10
cards-api:
image: cards-api:local
container_name: cards-api
@ -74,20 +55,12 @@ services:
depends_on:
cards-postgres:
condition: service_healthy
cards-minio:
condition: service_healthy
environment:
DATABASE_URL: 'postgresql://cards:${CARDS_DB_PASSWORD}@cards-postgres:5432/cards'
CARDS_API_PORT: 3081
CARDS_API_VERSION: ${CARDS_API_VERSION:-1.0.0}
CARDS_PUBLIC_URL: https://cardecky.mana.how
CARDS_PUBLIC_URL: https://wordeck.com
CARDS_DSGVO_SERVICE_KEY: ${CARDS_DSGVO_SERVICE_KEY:?missing CARDS_DSGVO_SERVICE_KEY}
CARDS_S3_ENDPOINT: cards-minio
CARDS_S3_PORT: 9000
CARDS_S3_USE_SSL: 'false'
CARDS_S3_ACCESS_KEY: cardsadmin
CARDS_S3_SECRET_KEY: ${CARDS_S3_SECRET_KEY}
CARDS_S3_BUCKET: cards-media
MANA_AUTH_URL: https://auth.mana.how
MANA_CREDITS_URL: https://credits.mana.how
CARDS_MANA_SERVICE_KEY: ${CARDS_MANA_SERVICE_KEY:-}
@ -114,14 +87,14 @@ services:
# SvelteKit `$env/dynamic/public` liest zur Runtime — daher
# hier statt als Build-Arg. Wert landet im SSR-Init-Snapshot
# und in client-fetches.
PUBLIC_CARDS_API_URL: https://cardecky-api.mana.how
PUBLIC_CARDS_API_URL: https://api.wordeck.com
PUBLIC_MANA_AUTH_URL: https://auth.mana.how
PUBLIC_AUTH_WEB_URL: https://auth.mana.how
# mana e.V. Apple-Developer-Team-ID. Wird ausgeliefert in
# /.well-known/apple-app-site-association für die cards-native
# Universal-Links (applinks:cardecky.mana.how).
PUBLIC_APPLE_TEAM_ID: QP3GLU8PH3
CARDS_API_URL: https://cardecky-api.mana.how
CARDS_API_URL: https://api.wordeck.com
NODE_ENV: production
ports:
- '127.0.0.1:5181:3000'

View file

@ -1,61 +0,0 @@
/**
* Image-Occlusion: Bild + N rechteckige Masken. Jede Maske wird zu
* einem eigenen Review (sub_index = sortierter Index der Mask-ID),
* im Prompt wird die aktive Maske als opakes Rechteck überlagert,
* andere Masken bleiben transparent.
*
* Field-Schema (in cards.fields):
* image_ref: string media_files.id (Phase 9k Storage)
* mask_regions: string JSON-Array von MaskRegion (siehe unten)
* note?: string optionale Bildunterschrift / Lerntipp
*
* Coordinaten: 0..1 relativ zur Bildgröße, damit das Rendering
* unabhängig vom Display-Skalierungsfaktor funktioniert.
*/
import { z } from 'zod';
export const MaskRegionSchema = z
.object({
id: z.string().min(1),
x: z.number().min(0).max(1),
y: z.number().min(0).max(1),
w: z.number().min(0).max(1),
h: z.number().min(0).max(1),
label: z.string().max(200).optional(),
})
.strict();
export type MaskRegion = z.infer<typeof MaskRegionSchema>;
export const MaskRegionsSchema = z.array(MaskRegionSchema).min(1).max(100);
/**
* Liest das `mask_regions`-Feld (JSON-String) und liefert die Liste,
* sortiert nach Mask-ID. Bei Parse- oder Schema-Fehler: leere Liste.
* Caller muss den 0-Fall als Validation-Error behandeln (analog
* Cloze ohne Cluster).
*/
export function parseMaskRegions(maskRegionsJson: string): MaskRegion[] {
let parsed: unknown;
try {
parsed = JSON.parse(maskRegionsJson);
} catch {
return [];
}
const result = MaskRegionsSchema.safeParse(parsed);
if (!result.success) return [];
return [...result.data].sort((a, b) => a.id.localeCompare(b.id));
}
/** Anzahl Mask-Regionen → Anzahl Reviews. */
export function maskRegionCount(maskRegionsJson: string): number {
return parseMaskRegions(maskRegionsJson).length;
}
/**
* Mapping Sub-Index Mask-Region. Sub-Index 0 = lexikographisch
* niedrigste Mask-ID, etc.
*/
export function maskForSubIndex(maskRegionsJson: string, subIndex: number): MaskRegion | null {
return parseMaskRegions(maskRegionsJson)[subIndex] ?? null;
}

View file

@ -12,5 +12,4 @@ export * from './fsrs.ts';
export * from './protocol/index.ts';
export * from './cloze.ts';
export * from './content-hash.ts';
export * from './image-occlusion.ts';
export * from './typing.ts';

View file

@ -1,32 +1,21 @@
import { z } from 'zod';
/**
* MVP-CardType-Set. Cloze in Phase 8 (Anki-Import) ergänzt,
* image-occlusion in Phase 9l. Weitere Erweiterung (type-in, audio,
* multiple-choice) bleibt im CardTypeFutureSchema vorbereitet.
* CardType-Set (Wordeck text-only ab 2026-05-17).
*
* Image-Occlusion und Audio-Front wurden mit dem Wordeck-Rebrand
* ersatzlos entfernt (siehe `mana/docs/playbooks/WORDECK_REBRAND.md`).
* Wordeck ist text-only.
*/
export const CardTypeSchema = z.enum([
'basic',
'basic-reverse',
'cloze',
'image-occlusion',
'audio-front',
'typing',
'multiple-choice',
]);
export type CardType = z.infer<typeof CardTypeSchema>;
/** Future-Set für Schema-Migration-Vorbereitung. */
export const CardTypeFutureSchema = z.enum([
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio-front',
'multiple-choice',
]);
/**
* Generischer Field-Slot. Konkrete Field-Sets pro Type werden runtime
* via `validateFieldsForType()` geprüft (siehe unten).
@ -40,16 +29,13 @@ export type CardFields = z.infer<typeof CardFieldsSchema>;
* Bruch hinzugefügt werden.
*/
export function validateFieldsForType(
type: CardType | z.infer<typeof CardTypeFutureSchema>,
type: CardType,
fields: CardFields
): { ok: true } | { ok: false; missing: string[] } {
const required: Record<string, string[]> = {
basic: ['front', 'back'],
'basic-reverse': ['front', 'back'],
cloze: ['text'],
'type-in': ['question', 'expected'],
'image-occlusion': ['image_ref', 'mask_regions'],
'audio-front': ['audio_ref', 'back'],
typing: ['front', 'answer'],
'multiple-choice': ['front', 'answer'],
};
@ -65,7 +51,6 @@ export const CardSchema = z
user_id: z.string().min(1),
type: CardTypeSchema,
fields: CardFieldsSchema,
media_refs: z.array(z.string()).default([]),
content_hash: z.string().optional().nullable(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
@ -79,7 +64,6 @@ export const CardCreateSchema = z
type: CardTypeSchema,
fields: CardFieldsSchema,
tags: z.array(z.string()).optional(),
media_refs: z.array(z.string()).optional(),
})
.strict()
.superRefine((val, ctx) => {
@ -98,7 +82,6 @@ export const CardUpdateSchema = z
.object({
fields: CardFieldsSchema.optional(),
tags: z.array(z.string()).optional(),
media_refs: z.array(z.string()).optional(),
})
.strict();
export type CardUpdate = z.infer<typeof CardUpdateSchema>;

View file

@ -1,122 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
MaskRegionSchema,
MaskRegionsSchema,
parseMaskRegions,
maskRegionCount,
maskForSubIndex,
} from '../src/image-occlusion.ts';
describe('MaskRegionSchema', () => {
it('akzeptiert valide Region', () => {
const r = MaskRegionSchema.safeParse({
id: 'm1',
x: 0.1,
y: 0.2,
w: 0.3,
h: 0.1,
});
expect(r.success).toBe(true);
});
it('akzeptiert mit Label', () => {
const r = MaskRegionSchema.safeParse({
id: 'm1',
x: 0,
y: 0,
w: 1,
h: 1,
label: 'Hippocampus',
});
expect(r.success).toBe(true);
});
it('lehnt Coordinaten außerhalb 0..1 ab', () => {
expect(MaskRegionSchema.safeParse({ id: 'm', x: 1.5, y: 0, w: 0.1, h: 0.1 }).success).toBe(
false
);
expect(MaskRegionSchema.safeParse({ id: 'm', x: 0, y: 0, w: -0.1, h: 0.1 }).success).toBe(
false
);
});
it('lehnt extra Felder ab (strict)', () => {
const r = MaskRegionSchema.safeParse({
id: 'm1',
x: 0,
y: 0,
w: 0.1,
h: 0.1,
malicious: 'x',
});
expect(r.success).toBe(false);
});
});
describe('MaskRegionsSchema', () => {
it('verlangt mindestens eine Region', () => {
expect(MaskRegionsSchema.safeParse([]).success).toBe(false);
});
it('akzeptiert mehrere Regionen', () => {
const r = MaskRegionsSchema.safeParse([
{ id: 'm1', x: 0, y: 0, w: 0.1, h: 0.1 },
{ id: 'm2', x: 0.5, y: 0.5, w: 0.1, h: 0.1 },
]);
expect(r.success).toBe(true);
});
it('cap bei 100 Regionen', () => {
const tooMany = Array.from({ length: 101 }, (_, i) => ({
id: `m${i}`,
x: 0,
y: 0,
w: 0.01,
h: 0.01,
}));
expect(MaskRegionsSchema.safeParse(tooMany).success).toBe(false);
});
});
describe('parseMaskRegions', () => {
it('parst und sortiert nach ID', () => {
const json = JSON.stringify([
{ id: 'm3', x: 0, y: 0, w: 0.1, h: 0.1 },
{ id: 'm1', x: 0, y: 0, w: 0.1, h: 0.1 },
{ id: 'm2', x: 0, y: 0, w: 0.1, h: 0.1 },
]);
const out = parseMaskRegions(json);
expect(out.map((r) => r.id)).toEqual(['m1', 'm2', 'm3']);
});
it('liefert leere Liste bei kaputtem JSON', () => {
expect(parseMaskRegions('not json')).toEqual([]);
});
it('liefert leere Liste bei Schema-Mismatch', () => {
expect(parseMaskRegions(JSON.stringify([{ x: 0, y: 0, w: 0.1, h: 0.1 }]))).toEqual([]);
});
});
describe('maskRegionCount + maskForSubIndex', () => {
const json = JSON.stringify([
{ id: 'm2', x: 0.1, y: 0.1, w: 0.2, h: 0.2 },
{ id: 'm1', x: 0.3, y: 0.3, w: 0.2, h: 0.2 },
]);
it('zählt Regionen', () => {
expect(maskRegionCount(json)).toBe(2);
});
it('mapt subIndex auf sortierte Mask', () => {
expect(maskForSubIndex(json, 0)?.id).toBe('m1');
expect(maskForSubIndex(json, 1)?.id).toBe('m2');
expect(maskForSubIndex(json, 2)).toBe(null);
});
it('returnt 0 / null bei kaputtem JSON', () => {
expect(maskRegionCount('garbage')).toBe(0);
expect(maskForSubIndex('garbage', 0)).toBe(null);
});
});