feat(text-only): Wordeck-Cutoff für Image-Occlusion + Audio + MinIO
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
1228eb4692
commit
e3c84a9249
42 changed files with 149 additions and 1955 deletions
36
apps/api/src/db/migrations/0005_wordeck_license_rename.sql
Normal file
36
apps/api/src/db/migrations/0005_wordeck_license_rename.sql
Normal 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');
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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'`
|
||||
),
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -70,7 +70,6 @@ export async function insertGeneratedDeck(
|
|||
userId,
|
||||
type: 'basic',
|
||||
fields: cr.fields,
|
||||
mediaRefs: [],
|
||||
contentHash: cr.contentHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -218,36 +218,21 @@ function mapNoteToCard(
|
|||
* wird gedroppt — das passiert z.B. wenn der Media-Upload für diese
|
||||
* Datei fehlschlägt.
|
||||
*
|
||||
* `<img>` → Markdown ``. `[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 ? `` : '';
|
||||
}
|
||||
);
|
||||
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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 ?? '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@
|
|||
categoryOpen = false;
|
||||
}
|
||||
|
||||
async function onSave(e: SubmitEvent) {
|
||||
async function onSave(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!canSave) return;
|
||||
saving = true;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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  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*');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue