Phase 9k: Media-Upload via MinIO-Container
Eigener cards-minio-Container im docker-compose (9100/9101 — Plattform
auf 9000/9001 bleibt isoliert). cardsadmin/cardsadmin als Dev-Default,
prod via env-Vars (CARDS_S3_*).
apps/api/src/services/storage.ts — schmaler StorageService um den
minio-Client. ensureBucket() ist idempotent (auto-create beim ersten
Upload). removeObjectsByPrefix() implementiert den DSGVO-Bucket-Sweep,
weil die S3-API kein Cascade kennt.
Neue Tabelle media_files in pgSchema('cards'):
id, user_id, object_key, mime_type, original_filename, size_bytes,
kind, created_at — kein FK auf cards (ein File kann mehreren Karten
gehören). objectKey-Format <userId>/<ulid>.<ext> für Bucket-Prefix-
Sweep beim DSGVO-Delete. Legacy mediaRefs bleibt als Slot.
Neuer Router /api/v1/media:
POST /upload — multipart, 25 MiB Default-Limit, image/audio/video
only (415 sonst), schreibt media_files-Row + speichert
in MinIO unter <userId>/<ulid>.<ext>
GET /:id — streamt aus MinIO mit Cache-Control: private,
immutable. Cross-User → 404 (nicht 403, anti-enumeration).
GET / — listet alle eigenen Files
DSGVO-Pfade (Service-Key + /me/delete) räumen jetzt auch media_files
+ MinIO-Bucket-Prefix mit ab. Storage-Sweep ist non-fatal — DB ist erst
konsistent gelöscht, dead bytes wären die schlimmstmögliche Folge.
Anki-Import: parse.ts sanitizeAnkiHtml akzeptiert wieder eine
Filename→URL-Map (war in Phase 8c gedroppt). import.ts lädt vor den
Karten alle referenzierten Media-Files via uploadMedia() in MinIO,
sammelt URLs, ersetzt Anki-Filenames durch /api/v1/media/<id>-Pfade
in `<img>` (Markdown) und `[sound:…]` (HTML <audio>). 4-fache Worker-
Concurrency.
apps/web/src/lib/markdown.ts: DOMPurify lässt jetzt <audio>/<video>/
<source> mit src/controls/preload-Attributen durch — sonst würden die
Audio-Tags aus dem Anki-Import gestrippt.
i18n-Strings (DE/EN) auf Media-Stage erweitert: stage_media,
done_media, what_works_media, dropzone_hint, preview_media.
import.what_skipped_media wird zur Bestätigung dass Media seit
Sprint 9k mit übernommen wird.
Manueller E2E-Smoke gegen lokale MinIO (cards-minio :9100):
- 1×1-PNG hochgeladen → 201 mit ID + URL
- /api/v1/media/<id> streamt 200 image/png 69 bytes (file-Identifikation
bestätigt)
- Cross-User → 404, ohne X-User-Id → 401, text/plain → 415
53 API-Tests grün (+4 neue media-Auth-Gate-Tests), 7 Web-Tests,
51 Domain-Tests, type-check + svelte-check 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7ae93dcf9
commit
c9eb0a6f80
20 changed files with 886 additions and 78 deletions
|
|
@ -6,37 +6,123 @@
|
|||
* (Anki-`::` zu ` / ` flacht die Hierarchie aus, wie im Original).
|
||||
* Karten werden mit sanitisiertem Markdown angelegt.
|
||||
*
|
||||
* Phase-8-MVP: Bilder + Audio werden gedroppt (siehe parse.ts
|
||||
* `sanitizeAnkiHtml`). Ein späterer Media-Pfad ist additiv.
|
||||
* Phase 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>`).
|
||||
*
|
||||
* Phase-9j-Re-Import-Dedupe: Vor dem Insert wird der content_hash der
|
||||
* Karte berechnet (gleiche Funktion wie der Server) und gegen die
|
||||
* existierende Hash-Liste des Users geprüft. Duplikate werden gezählt
|
||||
* und übersprungen — Re-Imports bringen also keine doppelten Karten
|
||||
* mehr ins Deck. Decks werden nicht dedupliziert (gewollt: zwei
|
||||
* .apkg-Files mit identischen Decknamen sollen sich nicht
|
||||
* versehentlich zusammenführen).
|
||||
* 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: 'decks' | 'cards' | 'done';
|
||||
stage: 'media' | '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 } = {}
|
||||
|
|
@ -45,22 +131,32 @@ export async function importParsedAnki(
|
|||
decksCreated: 0,
|
||||
cardsCreated: 0,
|
||||
cardsSkippedDuplicate: 0,
|
||||
mediaUploaded: 0,
|
||||
mediaFailed: 0,
|
||||
failed: 0,
|
||||
failures: [],
|
||||
};
|
||||
|
||||
// Vor dem Insert die Hash-Liste des Users laden — wenn der Endpoint
|
||||
// fehlschlägt (z.B. älterer Server vor Phase 9j), fallen wir
|
||||
// stillschweigend auf "kein Dedupe" zurück.
|
||||
// Hash-Set vor dem Loop laden (Phase 9j-Dedupe).
|
||||
const existingHashes = new Set<string>();
|
||||
try {
|
||||
const r = await listCardHashes();
|
||||
for (const h of r.hashes) existingHashes.add(h);
|
||||
} catch {
|
||||
// Dedupe bleibt aus — Karten werden eingefügt wie zuvor.
|
||||
// Dedupe bleibt aus (älterer Server o.ä.).
|
||||
}
|
||||
|
||||
// 1) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen.
|
||||
// 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.
|
||||
const ankiIdToDeckId = new Map<string, string>();
|
||||
let deckIdx = 0;
|
||||
for (const ankiDeck of parsed.decks) {
|
||||
|
|
@ -76,7 +172,6 @@ export async function importParsedAnki(
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback-Deck für Karten ohne explizit referenziertes Anki-Deck.
|
||||
let fallbackDeckId: string | null = null;
|
||||
const ensureFallbackDeck = async (): Promise<string | null> => {
|
||||
if (fallbackDeckId) return fallbackDeckId;
|
||||
|
|
@ -91,14 +186,14 @@ export async function importParsedAnki(
|
|||
}
|
||||
};
|
||||
|
||||
// 2) Cards — Felder sanitizen, content_hash prüfen, einfügen.
|
||||
// 3) Cards — sanitize mit URL-Map, 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);
|
||||
cleanFields[key] = sanitizeAnkiHtml(value, urlByFilename);
|
||||
}
|
||||
|
||||
const hash = await cardContentHash({ type: card.type, fields: cleanFields });
|
||||
|
|
@ -124,8 +219,6 @@ export async function importParsedAnki(
|
|||
fields: cleanFields,
|
||||
});
|
||||
result.cardsCreated++;
|
||||
// Hash sofort merken — derselbe Import könnte zwei identische
|
||||
// Karten enthalten (Anki-Drift), zweite würde sonst auch rein.
|
||||
existingHashes.add(hash);
|
||||
} catch (e) {
|
||||
result.failed++;
|
||||
|
|
|
|||
|
|
@ -213,23 +213,41 @@ function mapNoteToCard(
|
|||
/**
|
||||
* Convert Anki's HTML / image / sound markup to plain text + Markdown.
|
||||
*
|
||||
* Phase-8-MVP: Bilder + Audio werden ersatzlos gedroppt (Option A).
|
||||
* Ein späterer Media-Pfad (lokaler Cards-Upload-Endpunkt oder mana-media
|
||||
* via Phase 2 Auth-Föderation) kann hier eine Filename→URL-Map einsetzen,
|
||||
* die dann zu `<img>` / `<audio>`-Tags expandiert.
|
||||
* `mediaUrlByFilename` mapt den Anki-Filename (wie er im Card-Field
|
||||
* referenziert ist) auf eine echte App-URL. Was nicht in der Map ist,
|
||||
* 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).
|
||||
*/
|
||||
export function sanitizeAnkiHtml(html: string): string {
|
||||
// Bilder + Audio-Refs vollständig entfernen.
|
||||
const imgStripped = html.replace(/<img\b[^>]*>/gi, '');
|
||||
const soundStripped = imgStripped.replace(/\[sound:[^\]]+\]/g, '');
|
||||
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>` : '';
|
||||
});
|
||||
|
||||
return soundStripped
|
||||
return soundReplaced
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/?(?:b|strong)>/gi, '**')
|
||||
.replace(/<\/?(?:i|em)>/gi, '*')
|
||||
.replace(/<\/?p>/gi, '\n')
|
||||
.replace(/<\/?div>/gi, '\n')
|
||||
.replace(/<[^>]+>/gi, '')
|
||||
// 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, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
|
|
|
|||
42
apps/web/src/lib/api/media.ts
Normal file
42
apps/web/src/lib/api/media.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (devUser.id) headers['X-User-Id'] = devUser.id;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/media/upload`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers,
|
||||
});
|
||||
|
||||
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,7 +172,9 @@
|
|||
</div>
|
||||
{:else if stage === 'importing'}
|
||||
<div class="py-6 text-center text-sm text-[var(--color-muted)]" aria-live="polite">
|
||||
{#if progress.stage === 'decks'}
|
||||
{#if progress.stage === 'media'}
|
||||
{t('import.stage_media', { current: progress.current, total: progress.total })}
|
||||
{:else 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 })}
|
||||
|
|
@ -204,6 +206,14 @@
|
|||
{t('import.done_dupes', { n: result.cardsSkippedDuplicate })}
|
||||
</div>
|
||||
{/if}
|
||||
{#if result.mediaUploaded > 0 || result.mediaFailed > 0}
|
||||
<div class="text-[var(--color-muted)]">
|
||||
{t('import.done_media', {
|
||||
uploaded: result.mediaUploaded,
|
||||
failed: result.mediaFailed,
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
{#if result.failed > 0}
|
||||
<details class="text-[var(--color-danger)]">
|
||||
<summary class="cursor-pointer">{t('import.done_failures', { n: result.failed })}</summary>
|
||||
|
|
|
|||
|
|
@ -132,13 +132,14 @@ export const de: TranslationNode = {
|
|||
what_works_decks: 'Decks (Anki-Hierarchie Foo::Bar wird zu Foo / Bar).',
|
||||
what_works_basic: 'Basic + Basic-Reverse: Front/Back direkt.',
|
||||
what_works_cloze: 'Cloze: {{c1::…}} wird mit Sub-Index pro Cluster angelegt.',
|
||||
what_works_media: 'Bilder + Audio (eingebettet als Markdown bzw. <audio>-Tag).',
|
||||
what_skipped_title: 'Was nicht übernommen wird',
|
||||
what_skipped_media: 'Bilder + Audio (kommen mit der Plattform-Anbindung in einer späteren Phase).',
|
||||
what_skipped_media: '— (Bilder + Audio werden seit Phase 9k mit übernommen, siehe oben)',
|
||||
what_skipped_history: 'FSRS-Lernverlauf (Anki-Reviews werden bewusst neu aufgesetzt).',
|
||||
what_skipped_addons: 'Add-on-spezifische Card-Types (image-occlusion etc.).',
|
||||
anki_label: 'Aus Anki importieren',
|
||||
dropzone: '📦 .apkg-Datei hier ablegen oder klicken',
|
||||
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Bilder + Audio werden in dieser Phase nicht übernommen.',
|
||||
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Bilder + Audio werden mit übernommen (Limit 25 MB pro Datei).',
|
||||
parsing: 'Lese {file}…',
|
||||
preview_found: 'Gefunden in',
|
||||
preview_decks_one: '1 Deck',
|
||||
|
|
@ -146,17 +147,19 @@ export const de: TranslationNode = {
|
|||
preview_cards_one: '1 Karte',
|
||||
preview_cards: '{n} Karten',
|
||||
preview_breakdown: '({basic} basic, {basic_reverse} basic-reverse, {cloze} cloze)',
|
||||
preview_media: '{n} Medien (werden in dieser Phase NICHT übernommen)',
|
||||
preview_media: '{n} Medien werden mitgeladen',
|
||||
preview_skipped: '{n} übersprungen (unbekannter Typ)',
|
||||
preview_warnings: 'Hinweise ({n})',
|
||||
cancel: 'Abbrechen',
|
||||
import_now: 'Importieren',
|
||||
stage_media: 'Lade Medien hoch · {current} / {total}',
|
||||
stage_decks: 'Lege Decks an · {current} / {total}',
|
||||
stage_cards: 'Importiere Karten · {current} / {total}',
|
||||
stage_done: 'Fertig.',
|
||||
done_summary_one: '✓ {cards} Karten in 1 Deck angelegt.',
|
||||
done_summary: '✓ {cards} Karten in {decks} Decks angelegt.',
|
||||
done_dupes: '{n} Duplikate übersprungen (gleicher Inhalt schon vorhanden).',
|
||||
done_media: '{uploaded} Medien geladen, {failed} fehlgeschlagen.',
|
||||
done_failures: '{n} Fehler',
|
||||
done_more: 'Weitere Datei',
|
||||
error_label: 'Fehler: {msg}',
|
||||
|
|
|
|||
|
|
@ -129,13 +129,14 @@ export const en: TranslationNode = {
|
|||
what_works_decks: 'Decks (Anki hierarchy Foo::Bar becomes Foo / Bar).',
|
||||
what_works_basic: 'Basic + Basic-Reverse: front/back directly.',
|
||||
what_works_cloze: 'Cloze: {{c1::…}} is created with sub-index per cluster.',
|
||||
what_works_media: 'Images + audio (embedded as Markdown / <audio> tag).',
|
||||
what_skipped_title: 'What is not imported',
|
||||
what_skipped_media: 'Images + audio (will arrive with the platform integration in a later phase).',
|
||||
what_skipped_media: '— (Images + audio are imported since Sprint 9k, see above)',
|
||||
what_skipped_history: 'FSRS learning history (Anki reviews are deliberately reset).',
|
||||
what_skipped_addons: 'Add-on specific card types (image-occlusion etc.).',
|
||||
anki_label: 'Import from Anki',
|
||||
dropzone: '📦 Drop .apkg file here or click',
|
||||
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Images + audio are not imported in this phase.',
|
||||
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Images + audio are imported too (limit 25 MB per file).',
|
||||
parsing: 'Reading {file}…',
|
||||
preview_found: 'Found in',
|
||||
preview_decks_one: '1 deck',
|
||||
|
|
@ -143,17 +144,19 @@ export const en: TranslationNode = {
|
|||
preview_cards_one: '1 card',
|
||||
preview_cards: '{n} cards',
|
||||
preview_breakdown: '({basic} basic, {basic_reverse} basic-reverse, {cloze} cloze)',
|
||||
preview_media: '{n} media files (will NOT be imported in this phase)',
|
||||
preview_media: '{n} media files will be uploaded',
|
||||
preview_skipped: '{n} skipped (unknown type)',
|
||||
preview_warnings: 'Notes ({n})',
|
||||
cancel: 'Cancel',
|
||||
import_now: 'Import',
|
||||
stage_media: 'Uploading media · {current} / {total}',
|
||||
stage_decks: 'Creating decks · {current} / {total}',
|
||||
stage_cards: 'Importing cards · {current} / {total}',
|
||||
stage_done: 'Done.',
|
||||
done_summary_one: '✓ {cards} cards in 1 deck.',
|
||||
done_summary: '✓ {cards} cards in {decks} decks.',
|
||||
done_dupes: '{n} duplicates skipped (same content already exists).',
|
||||
done_media: '{uploaded} media uploaded, {failed} failed.',
|
||||
done_failures: '{n} errors',
|
||||
done_more: 'Another file',
|
||||
error_label: 'Error: {msg}',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ marked.setOptions({
|
|||
breaks: true,
|
||||
});
|
||||
|
||||
// DOMPurify-Default lässt <audio>/<video>/<source> NICHT durch. Wir
|
||||
// erlauben sie explizit, weil der Anki-Importer Audio-Tags einbettet.
|
||||
// `src` muss auf einer eigenen Allowlist sein, damit kein
|
||||
// `javascript:`-URI durchschlüpft.
|
||||
const ADD_TAGS = ['audio', 'video', 'source'];
|
||||
const ADD_ATTR = ['controls', 'preload', 'src', 'type', 'autoplay', 'loop'];
|
||||
|
||||
/**
|
||||
* Markdown → HTML, sanitized via DOMPurify.
|
||||
* Sicher gegen Stored-XSS aus User-Card-Inhalten.
|
||||
|
|
@ -18,5 +25,5 @@ export function renderMarkdown(source: string): string {
|
|||
if (!source) return '';
|
||||
const html = marked.parse(source, { async: false }) as string;
|
||||
if (typeof window === 'undefined') return html; // SSR-Fallback (selten Pfad)
|
||||
return DOMPurify.sanitize(html);
|
||||
return DOMPurify.sanitize(html, { ADD_TAGS, ADD_ATTR });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
<li>{t('import.what_works_decks')}</li>
|
||||
<li>{t('import.what_works_basic')}</li>
|
||||
<li>{t('import.what_works_cloze')}</li>
|
||||
<li>{t('import.what_works_media')}</li>
|
||||
</ul>
|
||||
<div class="mt-2 mb-1 font-medium text-[var(--color-fg)]">{t('import.what_skipped_title')}</div>
|
||||
<ul class="list-disc pl-4">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue