feat(web): CSV-Import/Export, Tab-Format-Import, PDF-Druckansicht

- CSV-Import: Dropzone für .csv-Dateien, unterstützt 2-spaltig (front,back)
  und 3-spaltig (type,front,back) inkl. cloze; Dedupe via contentHash
- CSV-Export: Button auf Deck-Detail-Seite, lädt type,front,back als .csv
- Tab-Format-Import (ehem. Quizlet): Textarea für tab-getrennte Zeilen;
  funktioniert mit Excel, Google Sheets, Notion und Quizlet-Extension;
  Anleitung erklärt Quizlet-Paywall-Workaround (Quizlet Exporter Extension)
- PDF-Druckansicht: Route /decks/[id]/print, A6-Karten mit alternierenden
  Vorder-/Rückseiten, CSS @page { size: A6 landscape } für Browser-Druck
- Import-Seite: Tab-Bar Anki | CSV | Tab-Format
- i18n: alle 5 Sprachen (DE/EN/FR/ES/IT) vollständig
- docs/FEATURE_IDEAS.md: strukturierte Feature-Liste als Planungsgrundlage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 18:27:39 +02:00
parent 9839737049
commit 926ff685c7
15 changed files with 1332 additions and 17 deletions

View file

@ -0,0 +1,246 @@
<script lang="ts">
import { parseCsv, type ParsedCsvCard } from '$lib/csv/parse.ts';
import { importCsvCards, type CsvImportResult, type CsvImportProgress } from '$lib/csv/import.ts';
import { t } from '$lib/i18n/index.svelte.ts';
let fileInput = $state<HTMLInputElement | null>(null);
let stage = $state<'idle' | 'parsing' | 'preview' | 'importing' | 'done' | 'error'>('idle');
let parsedCards = $state<ParsedCsvCard[]>([]);
let skipped = $state(0);
let warnings = $state<string[]>([]);
let result = $state<CsvImportResult | null>(null);
let error = $state<string | null>(null);
let fileName = $state('');
let deckName = $state('');
let progress = $state<CsvImportProgress>({ current: 0, total: 0 });
const typeBreakdown = $derived.by(() => {
const counts = { basic: 0, basicReverse: 0, cloze: 0 };
for (const c of parsedCards) {
if (c.type === 'basic') counts.basic++;
else if (c.type === 'basic-reverse') counts.basicReverse++;
else if (c.type === 'cloze') counts.cloze++;
}
return counts;
});
async function handleFile(file: File) {
error = null;
fileName = file.name;
if (!deckName) deckName = file.name.replace(/\.csv$/i, '');
stage = 'parsing';
try {
const text = await file.text();
const parsed = parseCsv(text);
parsedCards = parsed.cards;
skipped = parsed.skipped;
warnings = parsed.warnings;
stage = 'preview';
} catch (e: unknown) {
error = e instanceof Error ? e.message : '?';
stage = 'error';
}
}
function onPick(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const f = input.files?.[0];
if (f) handleFile(f);
input.value = '';
}
function onDrop(e: DragEvent) {
e.preventDefault();
const f = e.dataTransfer?.files?.[0];
if (f) handleFile(f);
}
async function confirmImport() {
if (!parsedCards.length || !deckName.trim()) return;
stage = 'importing';
progress = { current: 0, total: parsedCards.length };
try {
result = await importCsvCards(deckName.trim(), parsedCards, {
onProgress: (p) => {
progress = p;
},
});
stage = 'done';
} catch (e: unknown) {
error = e instanceof Error ? e.message : '?';
stage = 'error';
}
}
function reset() {
stage = 'idle';
parsedCards = [];
skipped = 0;
warnings = [];
result = null;
error = null;
fileName = '';
deckName = '';
}
</script>
<div class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="mb-3 text-sm font-medium">{t('import.csv_label')}</div>
{#if stage === 'idle' || stage === 'parsing'}
<label class="mb-3 block">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.csv_deck_label')}</span>
<input
type="text"
class="w-full rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-1.5 text-sm focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.csv_deck_placeholder')}
bind:value={deckName}
/>
</label>
{#if stage === 'idle'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-[hsl(var(--color-border))] px-4 py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))] transition-colors hover:border-[hsl(var(--color-primary))] hover:text-[hsl(var(--color-foreground))]"
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
onclick={() => fileInput?.click()}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput?.click();
}
}}
>
<div class="mb-1">{t('import.csv_dropzone')}</div>
<div class="text-xs">{t('import.csv_dropzone_hint')}</div>
</div>
<input
bind:this={fileInput}
type="file"
accept=".csv,text/csv"
class="hidden"
onchange={onPick}
/>
{:else}
<div class="py-4 text-center text-sm text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
{t('import.parsing', { file: fileName })}
</div>
{/if}
{:else if stage === 'preview'}
<div class="space-y-2 text-sm">
<div>
<span class="text-[hsl(var(--color-muted-foreground))]">{t('import.preview_found')}</span>
<code class="rounded bg-[hsl(var(--color-border))]/40 px-1 text-xs">{fileName}</code>:
</div>
<ul class="ml-4 list-disc">
<li>
{parsedCards.length === 1
? t('import.preview_cards_one')
: t('import.preview_cards', { n: parsedCards.length })}
{#if parsedCards.length > 0}
<span class="text-[hsl(var(--color-muted-foreground))]">
{t('import.preview_breakdown', {
basic: typeBreakdown.basic,
basic_reverse: typeBreakdown.basicReverse,
cloze: typeBreakdown.cloze,
})}
</span>
{/if}
</li>
{#if skipped > 0}
<li>{t('import.preview_skipped', { n: skipped })}</li>
{/if}
</ul>
{#if warnings.length > 0}
<details class="text-xs text-[hsl(var(--color-muted-foreground))]">
<summary class="cursor-pointer">{t('import.preview_warnings', { n: warnings.length })}</summary>
<ul class="mt-1 list-disc pl-4">
{#each warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
</ul>
</details>
{/if}
<label class="block pt-1">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.csv_deck_label')}</span>
<input
type="text"
class="w-full rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-1.5 text-sm focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.csv_deck_placeholder')}
bind:value={deckName}
/>
</label>
<div class="flex justify-end gap-2 pt-2">
<button
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
onclick={reset}
>
{t('import.cancel')}
</button>
<button
class="rounded bg-[hsl(var(--color-primary))] px-4 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))] hover:opacity-90 disabled:opacity-40"
onclick={confirmImport}
disabled={!deckName.trim() || parsedCards.length === 0}
>
{t('import.import_now')}
</button>
</div>
</div>
{:else if stage === 'importing'}
<div class="py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
{t('import.stage_cards', { current: progress.current, total: progress.total })}
<div
class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-[hsl(var(--color-border))]/40"
role="progressbar"
aria-valuemin="0"
aria-valuemax={progress.total}
aria-valuenow={progress.current}
>
<div
class="h-full bg-[hsl(var(--color-primary))] transition-all"
style="width: {progress.total === 0 ? 0 : (progress.current / progress.total) * 100}%"
></div>
</div>
</div>
{:else if stage === 'done' && result}
<div class="space-y-2 text-sm">
<div class="text-[hsl(var(--color-success))]">
{t('import.csv_done_summary', { cards: result.cardsCreated, deck: deckName })}
</div>
{#if result.cardsSkippedDuplicate > 0}
<div class="text-[hsl(var(--color-muted-foreground))]">
{t('import.done_dupes', { n: result.cardsSkippedDuplicate })}
</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>
<ul class="mt-1 list-disc pl-4 text-xs">
{#each result.failures.slice(0, 20) as msg (msg)}<li>{msg}</li>{/each}
</ul>
</details>
{/if}
<button
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
onclick={reset}
>
{t('import.done_more')}
</button>
</div>
{:else if stage === 'error'}
<div class="space-y-2 text-sm" role="alert">
<div class="text-[hsl(var(--color-error))]">{t('import.error_label', { msg: error ?? '?' })}</div>
<button
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
onclick={reset}
>
{t('import.retry')}
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,192 @@
<script lang="ts">
import { parseQuizlet } from '$lib/quizlet/parse.ts';
import { importCsvCards, type CsvImportResult, type CsvImportProgress } from '$lib/csv/import.ts';
import { t } from '$lib/i18n/index.svelte.ts';
let stage = $state<'idle' | 'preview' | 'importing' | 'done' | 'error'>('idle');
let pasteText = $state('');
let deckName = $state('');
let cardCount = $state(0);
let skipped = $state(0);
let warnings = $state<string[]>([]);
let result = $state<CsvImportResult | null>(null);
let error = $state<string | null>(null);
let progress = $state<CsvImportProgress>({ current: 0, total: 0 });
let parsedCards = $state<import('$lib/csv/parse.ts').ParsedCsvCard[]>([]);
function handlePreview() {
if (!pasteText.trim()) return;
const parsed = parseQuizlet(pasteText);
parsedCards = parsed.cards;
cardCount = parsed.cards.length;
skipped = parsed.skipped;
warnings = parsed.warnings;
stage = 'preview';
}
async function confirmImport() {
if (!parsedCards.length || !deckName.trim()) return;
stage = 'importing';
progress = { current: 0, total: parsedCards.length };
try {
result = await importCsvCards(deckName.trim(), parsedCards, {
onProgress: (p) => {
progress = p;
},
});
stage = 'done';
} catch (e: unknown) {
error = e instanceof Error ? e.message : '?';
stage = 'error';
}
}
function reset() {
stage = 'idle';
pasteText = '';
deckName = '';
parsedCards = [];
cardCount = 0;
skipped = 0;
warnings = [];
result = null;
error = null;
}
</script>
<div class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="mb-3 text-sm font-medium">{t('import.quizlet_label')}</div>
{#if stage === 'idle'}
<p class="mb-3 text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.quizlet_hint')}</p>
<label class="mb-3 block">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.quizlet_deck_label')}</span>
<input
type="text"
class="w-full rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-1.5 text-sm focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.quizlet_deck_placeholder')}
bind:value={deckName}
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.quizlet_paste_label')}</span>
<textarea
class="h-36 w-full resize-y rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-2 font-mono text-xs focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.quizlet_placeholder')}
bind:value={pasteText}
></textarea>
</label>
<div class="mt-3 flex justify-end">
<button
class="rounded bg-[hsl(var(--color-primary))] px-4 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))] hover:opacity-90 disabled:opacity-40"
onclick={handlePreview}
disabled={!pasteText.trim()}
>
{t('import.quizlet_preview')}
</button>
</div>
{:else if stage === 'preview'}
<div class="space-y-2 text-sm">
<ul class="ml-4 list-disc">
<li>
{cardCount === 1
? t('import.preview_cards_one')
: t('import.preview_cards', { n: cardCount })}
</li>
{#if skipped > 0}
<li>{t('import.preview_skipped', { n: skipped })}</li>
{/if}
</ul>
{#if warnings.length > 0}
<details class="text-xs text-[hsl(var(--color-muted-foreground))]">
<summary class="cursor-pointer">{t('import.preview_warnings', { n: warnings.length })}</summary>
<ul class="mt-1 list-disc pl-4">
{#each warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
</ul>
</details>
{/if}
<label class="block pt-1">
<span class="mb-1 block text-xs text-[hsl(var(--color-muted-foreground))]">{t('import.quizlet_deck_label')}</span>
<input
type="text"
class="w-full rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-surface))] px-3 py-1.5 text-sm focus:border-[hsl(var(--color-primary))] focus:outline-none"
placeholder={t('import.quizlet_deck_placeholder')}
bind:value={deckName}
/>
</label>
<div class="flex justify-end gap-2 pt-2">
<button
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
onclick={reset}
>
{t('import.cancel')}
</button>
<button
class="rounded bg-[hsl(var(--color-primary))] px-4 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))] hover:opacity-90 disabled:opacity-40"
onclick={confirmImport}
disabled={!deckName.trim() || parsedCards.length === 0}
>
{t('import.import_now')}
</button>
</div>
</div>
{:else if stage === 'importing'}
<div class="py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
{t('import.stage_cards', { current: progress.current, total: progress.total })}
<div
class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-[hsl(var(--color-border))]/40"
role="progressbar"
aria-valuemin="0"
aria-valuemax={progress.total}
aria-valuenow={progress.current}
>
<div
class="h-full bg-[hsl(var(--color-primary))] transition-all"
style="width: {progress.total === 0 ? 0 : (progress.current / progress.total) * 100}%"
></div>
</div>
</div>
{:else if stage === 'done' && result}
<div class="space-y-2 text-sm">
<div class="text-[hsl(var(--color-success))]">
{t('import.csv_done_summary', { cards: result.cardsCreated, deck: deckName })}
</div>
{#if result.cardsSkippedDuplicate > 0}
<div class="text-[hsl(var(--color-muted-foreground))]">
{t('import.done_dupes', { n: result.cardsSkippedDuplicate })}
</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>
<ul class="mt-1 list-disc pl-4 text-xs">
{#each result.failures.slice(0, 20) as msg (msg)}<li>{msg}</li>{/each}
</ul>
</details>
{/if}
<button
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
onclick={reset}
>
{t('import.done_more')}
</button>
</div>
{:else if stage === 'error'}
<div class="space-y-2 text-sm" role="alert">
<div class="text-[hsl(var(--color-error))]">{t('import.error_label', { msg: error ?? '?' })}</div>
<button
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
onclick={reset}
>
{t('import.retry')}
</button>
</div>
{/if}
</div>

View file

@ -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<string, string>;
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;
}

View file

@ -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<CsvImportResult> {
const result: CsvImportResult = {
cardsCreated: 0,
cardsSkippedDuplicate: 0,
failed: 0,
failures: [],
};
const existingHashes = new Set<string>();
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);
}

View file

@ -0,0 +1,124 @@
export interface ParsedCsv {
cards: ParsedCsvCard[];
skipped: number;
warnings: string[];
}
export interface ParsedCsvCard {
type: 'basic' | 'basic-reverse' | 'cloze';
fields: Record<string, string>;
}
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;
}

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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 };
}