diff --git a/apps/web/src/lib/components/CsvImport.svelte b/apps/web/src/lib/components/CsvImport.svelte new file mode 100644 index 0000000..3cda527 --- /dev/null +++ b/apps/web/src/lib/components/CsvImport.svelte @@ -0,0 +1,246 @@ + + +
+
{t('import.csv_label')}
+ + {#if stage === 'idle' || stage === 'parsing'} + + + {#if stage === 'idle'} + + +
e.preventDefault()} + ondrop={onDrop} + onclick={() => fileInput?.click()} + role="button" + tabindex="0" + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInput?.click(); + } + }} + > +
{t('import.csv_dropzone')}
+
{t('import.csv_dropzone_hint')}
+
+ + {:else} +
+ {t('import.parsing', { file: fileName })} +
+ {/if} + {:else if stage === 'preview'} +
+
+ {t('import.preview_found')} + {fileName}: +
+ + + {#if warnings.length > 0} +
+ {t('import.preview_warnings', { n: warnings.length })} +
    + {#each warnings.slice(0, 10) as w (w)}
  • {w}
  • {/each} +
+
+ {/if} + + + +
+ + +
+
+ {:else if stage === 'importing'} +
+ {t('import.stage_cards', { current: progress.current, total: progress.total })} +
+
+
+
+ {:else if stage === 'done' && result} +
+
+ {t('import.csv_done_summary', { cards: result.cardsCreated, deck: deckName })} +
+ {#if result.cardsSkippedDuplicate > 0} +
+ {t('import.done_dupes', { n: result.cardsSkippedDuplicate })} +
+ {/if} + {#if result.failed > 0} +
+ {t('import.done_failures', { n: result.failed })} +
    + {#each result.failures.slice(0, 20) as msg (msg)}
  • {msg}
  • {/each} +
+
+ {/if} + +
+ {:else if stage === 'error'} + + {/if} +
diff --git a/apps/web/src/lib/components/QuizletImport.svelte b/apps/web/src/lib/components/QuizletImport.svelte new file mode 100644 index 0000000..e374373 --- /dev/null +++ b/apps/web/src/lib/components/QuizletImport.svelte @@ -0,0 +1,192 @@ + + +
+
{t('import.quizlet_label')}
+ + {#if stage === 'idle'} +

{t('import.quizlet_hint')}

+ + + + + +
+ +
+ {:else if stage === 'preview'} +
+ + + {#if warnings.length > 0} +
+ {t('import.preview_warnings', { n: warnings.length })} +
    + {#each warnings.slice(0, 10) as w (w)}
  • {w}
  • {/each} +
+
+ {/if} + + + +
+ + +
+
+ {:else if stage === 'importing'} +
+ {t('import.stage_cards', { current: progress.current, total: progress.total })} +
+
+
+
+ {:else if stage === 'done' && result} +
+
+ {t('import.csv_done_summary', { cards: result.cardsCreated, deck: deckName })} +
+ {#if result.cardsSkippedDuplicate > 0} +
+ {t('import.done_dupes', { n: result.cardsSkippedDuplicate })} +
+ {/if} + {#if result.failed > 0} +
+ {t('import.done_failures', { n: result.failed })} +
    + {#each result.failures.slice(0, 20) as msg (msg)}
  • {msg}
  • {/each} +
+
+ {/if} + +
+ {:else if stage === 'error'} + + {/if} +
diff --git a/apps/web/src/lib/csv/export.ts b/apps/web/src/lib/csv/export.ts new file mode 100644 index 0000000..0537acb --- /dev/null +++ b/apps/web/src/lib/csv/export.ts @@ -0,0 +1,46 @@ +import type { Card } from '@cards/domain'; + +export function exportToCsv(cards: Card[]): string { + const rows: string[] = ['type,front,back']; + + for (const card of cards) { + const f = card.fields as Record; + let type: string; + let front: string; + let back: string; + + if (card.type === 'cloze') { + type = 'cloze'; + front = f.text ?? ''; + back = f.extra ?? ''; + } else if (card.type === 'basic' || card.type === 'basic-reverse') { + type = card.type; + front = f.front ?? ''; + back = f.back ?? ''; + } else { + // image-occlusion, audio-front etc. — not representable in CSV + continue; + } + + rows.push([type, front, back].map(escapeCsv).join(',')); + } + + return rows.join('\n'); +} + +export function downloadCsv(filename: string, content: string): void { + const blob = new Blob(['' + content], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +function escapeCsv(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) { + return '"' + value.replace(/"/g, '""') + '"'; + } + return value; +} diff --git a/apps/web/src/lib/csv/import.ts b/apps/web/src/lib/csv/import.ts new file mode 100644 index 0000000..a04cea9 --- /dev/null +++ b/apps/web/src/lib/csv/import.ts @@ -0,0 +1,75 @@ +import { cardContentHash } from '@cards/domain'; +import { createDeck } from '$lib/api/decks.ts'; +import { createCard, listCardHashes } from '$lib/api/cards.ts'; +import type { ParsedCsvCard } from './parse.ts'; + +export interface CsvImportResult { + cardsCreated: number; + cardsSkippedDuplicate: number; + failed: number; + failures: string[]; +} + +export interface CsvImportProgress { + current: number; + total: number; +} + +export async function importCsvCards( + deckName: string, + cards: ParsedCsvCard[], + opts: { onProgress?: (p: CsvImportProgress) => void } = {} +): Promise { + const result: CsvImportResult = { + cardsCreated: 0, + cardsSkippedDuplicate: 0, + failed: 0, + failures: [], + }; + + const existingHashes = new Set(); + try { + const r = await listCardHashes(); + for (const h of r.hashes) existingHashes.add(h); + } catch { + // Dedupe stays off on error + } + + let deckId: string; + try { + const deck = await createDeck({ name: deckName }); + deckId = deck.id; + } catch (e) { + result.failures.push(`Deck "${deckName}": ${errMessage(e)}`); + result.failed = cards.length; + return result; + } + + for (let i = 0; i < cards.length; i++) { + opts.onProgress?.({ current: i, total: cards.length }); + const card = cards[i]; + + const hash = await cardContentHash({ type: card.type, fields: card.fields }); + if (existingHashes.has(hash)) { + result.cardsSkippedDuplicate++; + continue; + } + + try { + await createCard({ deck_id: deckId, type: card.type, fields: card.fields }); + result.cardsCreated++; + existingHashes.add(hash); + } catch (e) { + result.failed++; + result.failures.push(errMessage(e)); + } + } + + opts.onProgress?.({ current: cards.length, total: cards.length }); + return result; +} + +function errMessage(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e); +} diff --git a/apps/web/src/lib/csv/parse.ts b/apps/web/src/lib/csv/parse.ts new file mode 100644 index 0000000..4cc67fd --- /dev/null +++ b/apps/web/src/lib/csv/parse.ts @@ -0,0 +1,124 @@ +export interface ParsedCsv { + cards: ParsedCsvCard[]; + skipped: number; + warnings: string[]; +} + +export interface ParsedCsvCard { + type: 'basic' | 'basic-reverse' | 'cloze'; + fields: Record; +} + +export function parseCsv(text: string): ParsedCsv { + const rows = parseCsvContent(text); + const cards: ParsedCsvCard[] = []; + let skipped = 0; + const warnings: string[] = []; + + if (rows.length === 0) return { cards, skipped, warnings }; + + // Detect header and column layout + const first = rows[0].map((c) => c.trim().toLowerCase()); + let startIdx = 0; + let hasTypeCol = false; + + if (first[0] === 'type' || first[0] === 'typ') { + startIdx = 1; + hasTypeCol = true; + } else if (first[0] === 'front' || first[0] === 'vorderseite' || first[0] === 'term') { + startIdx = 1; + hasTypeCol = false; + } + + for (let i = startIdx; i < rows.length; i++) { + const row = rows[i]; + if (row.every((f) => !f.trim())) continue; + + if (hasTypeCol) { + const typeRaw = (row[0] ?? '').trim().toLowerCase(); + const front = (row[1] ?? '').trim(); + const back = (row[2] ?? '').trim(); + + if (typeRaw === 'basic' || typeRaw === 'basic-reverse') { + if (!front) { + skipped++; + continue; + } + cards.push({ type: typeRaw, fields: { front, back } }); + } else if (typeRaw === 'cloze') { + if (!front) { + skipped++; + continue; + } + cards.push({ type: 'cloze', fields: { text: front, ...(back ? { extra: back } : {}) } }); + } else { + warnings.push(`Zeile ${i + 1}: Unbekannter Typ "${row[0] ?? ''}"`); + skipped++; + } + } else { + // 2-column: front, back → basic + const front = (row[0] ?? '').trim(); + const back = (row[1] ?? '').trim(); + if (!front) { + skipped++; + continue; + } + cards.push({ type: 'basic', fields: { front, back } }); + } + } + + return { cards, skipped, warnings }; +} + +function parseCsvContent(text: string): string[][] { + const rows: string[][] = []; + let fields: string[] = []; + let field = ''; + let inQuotes = false; + const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + let i = 0; + + while (i < normalized.length) { + const ch = normalized[i]; + + if (inQuotes) { + if (ch === '"') { + if (normalized[i + 1] === '"') { + field += '"'; + i += 2; + } else { + inQuotes = false; + i++; + } + } else { + field += ch; + i++; + } + } else { + if (ch === '"') { + inQuotes = true; + i++; + } else if (ch === ',') { + fields.push(field); + field = ''; + i++; + } else if (ch === '\n') { + fields.push(field); + rows.push(fields); + fields = []; + field = ''; + i++; + } else { + field += ch; + i++; + } + } + } + + if (field || fields.length > 0) { + fields.push(field); + rows.push(fields); + } + + return rows; +} diff --git a/apps/web/src/lib/i18n/de.ts b/apps/web/src/lib/i18n/de.ts index 32579e4..6696a1f 100644 --- a/apps/web/src/lib/i18n/de.ts +++ b/apps/web/src/lib/i18n/de.ts @@ -53,6 +53,8 @@ export const de: TranslationNode = { card_delete_confirm: 'Karte wirklich löschen? Reviews werden mit gelöscht.', fan_aria: 'Aufgefächerte Karten von Stapel "{name}"', card_open: 'Karte öffnen — {type}', + export_csv: 'CSV', + print_cards: 'Drucken', }, deck_stack: { aria_label: 'Stapel "{name}" — {cards} Karten, {due} fällig', @@ -200,6 +202,45 @@ export const de: TranslationNode = { done_more: 'Weitere Datei', error_label: 'Fehler: {msg}', retry: 'Erneut versuchen', + tab_anki: 'Anki', + tab_csv: 'CSV', + tab_quizlet: 'Tab-Format', + csv_label: 'CSV-Datei importieren', + csv_dropzone: '📄 CSV-Datei hier ablegen oder klicken', + csv_dropzone_hint: + 'Format: type,front,back — erste Zeile optional als Kopfzeile. Unterstützt: basic, basic-reverse, cloze.', + csv_deck_label: 'Deck-Name', + csv_deck_placeholder: 'Mein importiertes Deck', + csv_done_summary: '✓ {cards} Karten in "{deck}" angelegt.', + csv_format_title: 'CSV-Format', + csv_format_example: + 'type,front,back\nbasic,Was ist 2+2?,4\nbasic-reverse,Paris,Hauptstadt Frankreichs\ncloze,"Die {{c1::Mitochondrien}} sind die Kraftwerke",', + csv_format_note: 'Ohne type-Spalte: alle Karten werden als basic angelegt.', + quizlet_label: 'Tab-getrenntes Format importieren', + quizlet_hint: + 'Funktioniert mit Quizlet (via Browser-Extension), Excel, Notion, Google Sheets und jedem anderen Tool, das tab-getrennte Zeilen kopieren kann.', + quizlet_paste_label: 'Text einfügen (Begriff Tab Definition)', + quizlet_placeholder: 'Begriff\tDefinition', + quizlet_deck_label: 'Deck-Name', + quizlet_deck_placeholder: 'Mein importiertes Deck', + quizlet_preview: 'Vorschau', + quizlet_how_title: 'Woher bekomme ich das Format?', + quizlet_how_1: + 'Excel / Google Sheets: zwei Spalten auswählen, kopieren → hier einfügen.', + quizlet_how_2: + 'Notion-Tabelle: Spalten markieren, kopieren → hier einfügen.', + quizlet_how_3: + 'Quizlet: Export ist kostenpflichtig. Gratis-Workaround: Browser-Extension „Quizlet Exporter" installieren → Tab-CSV exportieren → hier einfügen.', + }, + print: { + title: '{deck} drucken', + loading: 'Lade…', + btn: 'Drucken', + back: '← Zurück', + n_cards_one: '1 Karte', + n_cards: '{n} Karten', + front: 'Vorderseite', + back_side: 'Rückseite', }, inbox_banner: { label: '📥 Inbox', diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index c394f5f..81744cf 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -50,6 +50,8 @@ export const en: TranslationNode = { card_delete_confirm: 'Really delete card? Reviews will be deleted with it.', fan_aria: 'Fanned cards from stack "{name}"', card_open: 'Open card — {type}', + export_csv: 'CSV', + print_cards: 'Print', }, deck_stack: { aria_label: 'Stack "{name}" — {cards} cards, {due} due', @@ -197,6 +199,43 @@ export const en: TranslationNode = { done_more: 'Another file', error_label: 'Error: {msg}', retry: 'Try again', + tab_anki: 'Anki', + tab_csv: 'CSV', + tab_quizlet: 'Tab format', + csv_label: 'Import CSV file', + csv_dropzone: '📄 Drop CSV file here or click', + csv_dropzone_hint: + 'Format: type,front,back — header row is optional. Supported: basic, basic-reverse, cloze.', + csv_deck_label: 'Deck name', + csv_deck_placeholder: 'My imported deck', + csv_done_summary: '✓ {cards} cards added to "{deck}".', + csv_format_title: 'CSV format', + csv_format_example: + 'type,front,back\nbasic,What is 2+2?,4\nbasic-reverse,Paris,Capital of France\ncloze,"The {{c1::mitochondria}} are the powerhouse",', + csv_format_note: 'Without a type column all cards are created as basic.', + quizlet_label: 'Import tab-separated format', + quizlet_hint: + 'Works with Quizlet (via browser extension), Excel, Notion, Google Sheets, or any tool that can copy tab-separated rows.', + quizlet_paste_label: 'Paste text (term Tab definition)', + quizlet_placeholder: 'Term\tDefinition', + quizlet_deck_label: 'Deck name', + quizlet_deck_placeholder: 'My imported deck', + quizlet_preview: 'Preview', + quizlet_how_title: 'Where to get this format', + quizlet_how_1: 'Excel / Google Sheets: select two columns, copy → paste here.', + quizlet_how_2: 'Notion table: select columns, copy → paste here.', + quizlet_how_3: + 'Quizlet: export is paywalled. Free workaround: install the "Quizlet Exporter" browser extension → export as tab CSV → paste here.', + }, + print: { + title: 'Print {deck}', + loading: 'Loading…', + btn: 'Print', + back: '← Back', + n_cards_one: '1 card', + n_cards: '{n} cards', + front: 'Front', + back_side: 'Back', }, inbox_banner: { label: '📥 Inbox', diff --git a/apps/web/src/lib/i18n/es.ts b/apps/web/src/lib/i18n/es.ts index 46924f8..65cdfd3 100644 --- a/apps/web/src/lib/i18n/es.ts +++ b/apps/web/src/lib/i18n/es.ts @@ -50,6 +50,8 @@ export const es: TranslationNode = { card_delete_confirm: '¿Eliminar la tarjeta? Los repasos se eliminarán junto con ella.', fan_aria: 'Tarjetas desplegadas del mazo "{name}"', card_open: 'Abrir tarjeta — {type}', + export_csv: 'CSV', + print_cards: 'Imprimir', }, deck_stack: { aria_label: 'Mazo "{name}" — {cards} tarjetas, {due} pendientes', @@ -197,6 +199,43 @@ export const es: TranslationNode = { done_more: 'Otro archivo', error_label: 'Error: {msg}', retry: 'Reintentar', + tab_anki: 'Anki', + tab_csv: 'CSV', + tab_quizlet: 'Formato tab', + csv_label: 'Importar archivo CSV', + csv_dropzone: '📄 Arrastra un archivo CSV aquí o haz clic', + csv_dropzone_hint: + 'Formato: type,front,back — cabecera opcional. Admite: basic, basic-reverse, cloze.', + csv_deck_label: 'Nombre del mazo', + csv_deck_placeholder: 'Mi mazo importado', + csv_done_summary: '✓ {cards} tarjetas añadidas a "{deck}".', + csv_format_title: 'Formato CSV', + csv_format_example: + 'type,front,back\nbasic,What is 2+2?,4\nbasic-reverse,Paris,Capital of France\ncloze,"The {{c1::mitochondria}} are the powerhouse",', + csv_format_note: 'Sin columna type, todas las tarjetas se crean como basic.', + quizlet_label: 'Importar formato tabulado', + quizlet_hint: + 'Funciona con Quizlet (mediante extensión), Excel, Notion, Google Sheets o cualquier herramienta que copie filas separadas por tabuladores.', + quizlet_paste_label: 'Pegar texto (término Tabulador definición)', + quizlet_placeholder: 'Término\tDefinición', + quizlet_deck_label: 'Nombre del mazo', + quizlet_deck_placeholder: 'Mi mazo importado', + quizlet_preview: 'Vista previa', + quizlet_how_title: '¿De dónde viene este formato?', + quizlet_how_1: 'Excel / Google Sheets: selecciona dos columnas, copia → pega aquí.', + quizlet_how_2: 'Tabla de Notion: selecciona las columnas, copia → pega aquí.', + quizlet_how_3: + 'Quizlet: la exportación es de pago. Alternativa gratuita: instala la extensión "Quizlet Exporter" → exporta como CSV tabulado → pega aquí.', + }, + print: { + title: 'Imprimir {deck}', + loading: 'Cargando…', + btn: 'Imprimir', + back: '← Volver', + n_cards_one: '1 tarjeta', + n_cards: '{n} tarjetas', + front: 'Anverso', + back_side: 'Reverso', }, inbox_banner: { label: '📥 Bandeja', diff --git a/apps/web/src/lib/i18n/fr.ts b/apps/web/src/lib/i18n/fr.ts index 72af9e4..6f38d83 100644 --- a/apps/web/src/lib/i18n/fr.ts +++ b/apps/web/src/lib/i18n/fr.ts @@ -50,6 +50,8 @@ export const fr: TranslationNode = { card_delete_confirm: 'Supprimer la carte ? Les révisions seront supprimées avec elle.', fan_aria: 'Cartes étalées de la pile « {name} »', card_open: 'Ouvrir la carte — {type}', + export_csv: 'CSV', + print_cards: 'Imprimer', }, deck_stack: { aria_label: 'Pile « {name} » — {cards} cartes, {due} à réviser', @@ -197,6 +199,43 @@ export const fr: TranslationNode = { done_more: 'Autre fichier', error_label: 'Erreur : {msg}', retry: 'Réessayer', + tab_anki: 'Anki', + tab_csv: 'CSV', + tab_quizlet: 'Format tab', + csv_label: 'Importer un fichier CSV', + csv_dropzone: '📄 Déposez un fichier CSV ici ou cliquez', + csv_dropzone_hint: + 'Format : type,front,back — en-tête facultatif. Pris en charge : basic, basic-reverse, cloze.', + csv_deck_label: 'Nom du paquet', + csv_deck_placeholder: 'Mon paquet importé', + csv_done_summary: '✓ {cards} cartes ajoutées dans « {deck} ».', + csv_format_title: 'Format CSV', + csv_format_example: + 'type,front,back\nbasic,What is 2+2?,4\nbasic-reverse,Paris,Capital of France\ncloze,"The {{c1::mitochondria}} are the powerhouse",', + csv_format_note: 'Sans colonne type, toutes les cartes sont créées en basic.', + quizlet_label: 'Importer le format tabulé', + quizlet_hint: + 'Fonctionne avec Quizlet (via extension), Excel, Notion, Google Sheets ou tout outil copiant des lignes séparées par des tabulations.', + quizlet_paste_label: 'Coller le texte (terme Tabulation définition)', + quizlet_placeholder: 'Terme\tDéfinition', + quizlet_deck_label: 'Nom du paquet', + quizlet_deck_placeholder: 'Mon paquet importé', + quizlet_preview: 'Aperçu', + quizlet_how_title: "D'où vient ce format ?", + quizlet_how_1: 'Excel / Google Sheets : sélectionner deux colonnes, copier → coller ici.', + quizlet_how_2: 'Tableau Notion : sélectionner les colonnes, copier → coller ici.', + quizlet_how_3: + 'Quizlet : export payant. Solution gratuite : installer l\'extension « Quizlet Exporter » → exporter en CSV tabulé → coller ici.', + }, + print: { + title: 'Imprimer {deck}', + loading: 'Chargement…', + btn: 'Imprimer', + back: '← Retour', + n_cards_one: '1 carte', + n_cards: '{n} cartes', + front: 'Recto', + back_side: 'Verso', }, inbox_banner: { label: '📥 Boîte de réception', diff --git a/apps/web/src/lib/i18n/it.ts b/apps/web/src/lib/i18n/it.ts index a593887..9240ab6 100644 --- a/apps/web/src/lib/i18n/it.ts +++ b/apps/web/src/lib/i18n/it.ts @@ -50,6 +50,8 @@ export const it: TranslationNode = { card_delete_confirm: 'Eliminare la carta? I ripassi verranno eliminati insieme ad essa.', fan_aria: 'Carte aperte a ventaglio dal mazzo "{name}"', card_open: 'Apri carta — {type}', + export_csv: 'CSV', + print_cards: 'Stampa', }, deck_stack: { aria_label: 'Mazzo "{name}" — {cards} carte, {due} da ripassare', @@ -197,6 +199,43 @@ export const it: TranslationNode = { done_more: 'Un altro file', error_label: 'Errore: {msg}', retry: 'Riprova', + tab_anki: 'Anki', + tab_csv: 'CSV', + tab_quizlet: 'Formato tab', + csv_label: 'Importa file CSV', + csv_dropzone: '📄 Trascina un file CSV qui o clicca', + csv_dropzone_hint: + 'Formato: type,front,back — intestazione facoltativa. Supporta: basic, basic-reverse, cloze.', + csv_deck_label: 'Nome del mazzo', + csv_deck_placeholder: 'Il mio mazzo importato', + csv_done_summary: '✓ {cards} carte aggiunte a "{deck}".', + csv_format_title: 'Formato CSV', + csv_format_example: + 'type,front,back\nbasic,What is 2+2?,4\nbasic-reverse,Paris,Capital of France\ncloze,"The {{c1::mitochondria}} are the powerhouse",', + csv_format_note: 'Senza colonna type, tutte le carte vengono create come basic.', + quizlet_label: 'Importa formato tabulato', + quizlet_hint: + 'Funziona con Quizlet (tramite estensione), Excel, Notion, Google Sheets o qualsiasi strumento che copia righe separate da tabulazioni.', + quizlet_paste_label: 'Incolla testo (termine Tab definizione)', + quizlet_placeholder: 'Termine\tDefinizione', + quizlet_deck_label: 'Nome del mazzo', + quizlet_deck_placeholder: 'Il mio mazzo importato', + quizlet_preview: 'Anteprima', + quizlet_how_title: 'Da dove viene questo formato?', + quizlet_how_1: 'Excel / Google Sheets: seleziona due colonne, copia → incolla qui.', + quizlet_how_2: 'Tabella Notion: seleziona le colonne, copia → incolla qui.', + quizlet_how_3: + 'Quizlet: l\'esportazione è a pagamento. Alternativa gratuita: installa l\'estensione "Quizlet Exporter" → esporta come CSV tabulato → incolla qui.', + }, + print: { + title: 'Stampa {deck}', + loading: 'Caricamento…', + btn: 'Stampa', + back: '← Indietro', + n_cards_one: '1 carta', + n_cards: '{n} carte', + front: 'Fronte', + back_side: 'Retro', }, inbox_banner: { label: '📥 In arrivo', diff --git a/apps/web/src/lib/quizlet/parse.ts b/apps/web/src/lib/quizlet/parse.ts new file mode 100644 index 0000000..545dcdb --- /dev/null +++ b/apps/web/src/lib/quizlet/parse.ts @@ -0,0 +1,40 @@ +import type { ParsedCsvCard } from '$lib/csv/parse.ts'; + +export interface ParsedQuizlet { + cards: ParsedCsvCard[]; + skipped: number; + warnings: string[]; +} + +/** Parses Quizlet's tab-separated export (Term \t Definition, one per line). */ +export function parseQuizlet(text: string): ParsedQuizlet { + const cards: ParsedCsvCard[] = []; + let skipped = 0; + const warnings: string[] = []; + + const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) continue; + + const tabIdx = line.indexOf('\t'); + if (tabIdx === -1) { + warnings.push(`Zeile ${i + 1}: Kein Tab-Trennzeichen gefunden`); + skipped++; + continue; + } + + const front = line.slice(0, tabIdx).trim(); + const back = line.slice(tabIdx + 1).trim(); + + if (!front) { + skipped++; + continue; + } + + cards.push({ type: 'basic', fields: { front, back } }); + } + + return { cards, skipped, warnings }; +} diff --git a/apps/web/src/routes/decks/[id]/+page.svelte b/apps/web/src/routes/decks/[id]/+page.svelte index e4867a2..c76baa8 100644 --- a/apps/web/src/routes/decks/[id]/+page.svelte +++ b/apps/web/src/routes/decks/[id]/+page.svelte @@ -16,6 +16,7 @@ import { Image, CaretRight, DotsThree, PencilSimple, Trash } from '@mana/shared-icons'; import { marked } from 'marked'; import { apiErrorMessage } from '$lib/api/error.ts'; + import { exportToCsv, downloadCsv } from '$lib/csv/export.ts'; function md(text: string): string { return marked.parse(text, { async: false }) as string; @@ -102,6 +103,12 @@ if (card.type === 'basic') return f.back ?? null; return null; } + + function onExportCsv() { + if (!deck || !cards.length) return; + const csv = exportToCsv(cards); + downloadCsv(`${deck.name}.csv`, csv); + } @@ -124,6 +131,14 @@ + {t('deck_detail.new_card')} + {#if cards.length > 0} + + + {t('deck_detail.print_cards')} + + {/if} + {/if} + + +{#if loading} +

{t('print.loading')}

+{:else if error} +

{error}

+{:else if deck} +
+ {#each cards as card (card.id)} + {@const front = cardFront(card)} + {@const back = cardBack(card)} + + +
+
{t('print.front')}
+
{front}
+ +
+ + +
+
{t('print.back_side')}
+
{back || '—'}
+ +
+ {/each} +
+{/if} + + diff --git a/apps/web/src/routes/import/+page.svelte b/apps/web/src/routes/import/+page.svelte index 6a2c1a1..98c1046 100644 --- a/apps/web/src/routes/import/+page.svelte +++ b/apps/web/src/routes/import/+page.svelte @@ -3,8 +3,13 @@ import { goto } from '$app/navigation'; import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import AnkiImport from '$lib/components/AnkiImport.svelte'; + import CsvImport from '$lib/components/CsvImport.svelte'; + import QuizletImport from '$lib/components/QuizletImport.svelte'; import { t } from '$lib/i18n/index.svelte.ts'; + type Tab = 'anki' | 'csv' | 'quizlet'; + let activeTab = $state('anki'); + onMount(() => { if (!devUser.id) { goto('/'); @@ -20,23 +25,75 @@

{t('import.title')}

{t('import.intro')}

-
- + +
+ {#each (['anki', 'csv', 'quizlet'] as Tab[]) as tab (tab)} + + {/each}
- +
+ {#if activeTab === 'anki'} + + + + {:else if activeTab === 'csv'} + + + + {:else} + + + + {/if} +
+ + diff --git a/docs/FEATURE_IDEAS.md b/docs/FEATURE_IDEAS.md new file mode 100644 index 0000000..9dbc580 --- /dev/null +++ b/docs/FEATURE_IDEAS.md @@ -0,0 +1,94 @@ +# Feature Ideas + +Stand: 2026-05-11. Basiert auf einer Analyse des aktuellen Cardecky-Stands (Phasen 0–12). + +--- + +## Lern-Erlebnis + +### Schema-ready (nur UI fehlt) + +| Feature | Kartentyp | Notiz | +|---------|-----------|-------| +| Hör-Verständnis | `audio-front` | Schema + Renderer vorbereitet | +| Tipp-Antwort | `typing` | Fuzzy-Matching-Logik in `domain/typing.ts` | +| Multiple Choice | `multiple-choice` | Schema vorbereitet, Form-Komponente fehlt | + +### Scheduler-Verbesserungen + +- **Card Burial / Suspension** — Karten temporär deaktivieren ohne Löschen; häufig angefragtes Anki-Feature +- **Geschwister-Burial** — Cloze-Cluster und basic-reverse-Seiten nicht am selben Tag wiederholen +- **Custom Study Sessions** — Gefilterte Sitzungen: nur neue Karten, nur Fehler der letzten Woche, nach Tag filtern +- **Subdeck-Unterstützung** — Hierarchische Deck-Struktur (z. B. Vokabeln → Nomen / Verben) + +--- + +## Gamification & Motivation + +- **Daily Streaks** — Tägliche Lernkette mit optionalem Freeze-Token +- **XP + Badges** — Meilensteine (erstes Deck, 100 Karten, 30-Tage-Streak) +- **Tages-Ziele** — "Heute: 20 Karten" mit Progress-Bar im Dashboard +- **Push/Email-Reminders** — "Du hast heute noch 15 fällige Karten" via mana-notify +- **Estimated Mastery Date** — "Dieses Deck beherrschst du voraussichtlich in 3 Wochen" (aus FSRS-Parametern berechenbar) + +--- + +## KI-Features + +- **Auto-Cloze-Generator** — Text markieren → `{{c1::...}}` automatisch einfügen +- **Card-Split-Vorschlag** — KI erkennt informationsreiche Karten und schlägt Aufteilung vor +- **Erklär-Modus** — Nach falscher Antwort: KI erklärt den Zusammenhang (opt-in) +- **Auto-Tagging** — Karten beim Erstellen / Importieren semantisch taggen +- **Duplicate Detection** — Semantische Ähnlichkeit über Decks hinweg erkennen +- **Card Quality Score** — Hinweis: "Diese Karte hat zu viel Text" + Verbesserungsvorschlag + +--- + +## Analytics & Insights + +- **Vergessenskurven-Visualisierung** — Pro Deck und Tag, aus FSRS-State ableitbar +- **Retention-Rate** — Aufgeschlüsselt nach Kategorie und Sprache +- **Lernzeit-Tracking** — Minuten pro Session, Wochentrend +- **Karten-Schwierigkeits-Heatmap** — Welche Karten kosten die meiste Review-Zeit +- **Wöchentliche Zusammenfassung** — In-App oder per Email via mana-notify + +--- + +## Import / Export + +- **CSV Import/Export** — Einfachste Interop, relevant für Lehrer und Nutzer-Migration +- **PDF Export** — Druckbare Karteikarten (A6-Format, vorder-/rückseitig) +- **Web Clipper** (Browser-Extension) — Markierter Text → sofort neue Karte; eigenes Projekt +- **Quizlet Import** — Größte Nutzerbasis im Markt, hohe Migrations-Relevanz +- **SuperMemo XML** — Für Power-User aus dem SM-Ecosystem +- **FSRS-State Export** — Lernstand als JSON exportieren für Backup und Migration + +--- + +## Zusammenarbeit & Community + +- **Study Spaces** — Gemeinsame Decks für Schulklassen und Lerngruppen (braucht mana-auth Gruppen-Konzept) +- **Deck-Ratings & Kommentare** — Qualitätssicherung im Marketplace durch Community +- **Study Challenges** — Mit Freunden auf demselben Deck messen +- **Kreator-Analytics** — Für Marketplace-Publisher: Views, Forks, Abonnenten-Retention +- **Collaborative Decks** — Team-Editing mit Rollen (Maintainer / Contributor) + +--- + +## UX / Plattform + +- **PWA Offline-Support** — Service Worker + lokaler Lern-Cache; erfordert Entscheidung über FSRS-State-Sync-Strategie (server-authoritative vs. lokal) +- **Keyboard Shortcuts im Study-Mode** — Space = Antwort zeigen, 1–4 = Rating +- **Dark Mode / Theme-Switcher** — In-App-Auswahl statt nur System-Präferenz +- **Bulk-Operationen** — Mehrere Karten auswählen, verschieben, taggen, löschen +- **Dynamic Decks (Smart Playlists)** — Automatisch gefiltert: z. B. "Alle Karten mit Tag 'Grammatik' aus 3 Decks" +- **Card History** — Lernverlauf pro Karte: wann wie bewertet + +--- + +## Offene Punkte + +- **Schnell umsetzbar / hoher ROI:** Keyboard Shortcuts, Daily Streaks, CSV-Import — geringer Aufwand, spürbare UX-Verbesserung +- **Web Clipper** ist ein separates Browser-Extension-Projekt und braucht einen eigenen Scope +- **PWA Offline** ist der größte Architektur-Trade-off: die aktuelle server-authoritative FSRS-Architektur müsste um einen lokalen Sync-Layer erweitert werden +- **Study Spaces** setzt ein Gruppen-Konzept in mana-auth voraus, das noch nicht existiert