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:
parent
9839737049
commit
926ff685c7
15 changed files with 1332 additions and 17 deletions
246
apps/web/src/lib/components/CsvImport.svelte
Normal file
246
apps/web/src/lib/components/CsvImport.svelte
Normal 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>
|
||||
192
apps/web/src/lib/components/QuizletImport.svelte
Normal file
192
apps/web/src/lib/components/QuizletImport.svelte
Normal 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>
|
||||
46
apps/web/src/lib/csv/export.ts
Normal file
46
apps/web/src/lib/csv/export.ts
Normal 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;
|
||||
}
|
||||
75
apps/web/src/lib/csv/import.ts
Normal file
75
apps/web/src/lib/csv/import.ts
Normal 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);
|
||||
}
|
||||
124
apps/web/src/lib/csv/parse.ts
Normal file
124
apps/web/src/lib/csv/parse.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
40
apps/web/src/lib/quizlet/parse.ts
Normal file
40
apps/web/src/lib/quizlet/parse.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={closeMenu} />
|
||||
|
|
@ -124,6 +131,14 @@
|
|||
<a href="/cards/new?deck={deck.id}" class="btn-outline">
|
||||
+ {t('deck_detail.new_card')}
|
||||
</a>
|
||||
{#if cards.length > 0}
|
||||
<button type="button" class="btn-outline" onclick={onExportCsv} title={t('deck_detail.export_csv')}>
|
||||
{t('deck_detail.export_csv')}
|
||||
</button>
|
||||
<a href="/decks/{deck.id}/print" class="btn-outline" title={t('deck_detail.print_cards')}>
|
||||
{t('deck_detail.print_cards')}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
|
|
|
|||
229
apps/web/src/routes/decks/[id]/print/+page.svelte
Normal file
229
apps/web/src/routes/decks/[id]/print/+page.svelte
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Card, Deck } from '@cards/domain';
|
||||
import { getDeck } from '$lib/api/decks.ts';
|
||||
import { listCards } from '$lib/api/cards.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let deck = $state<Deck | null>(null);
|
||||
let cards = $state<Card[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const deckId = $derived(page.params.id ?? '');
|
||||
|
||||
onMount(async () => {
|
||||
if (!devUser.id) {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [d, c] = await Promise.all([getDeck(deckId), listCards(deckId)]);
|
||||
deck = d;
|
||||
cards = c.cards;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : '?';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function cardFront(card: Card): string {
|
||||
const f = card.fields as Record<string, string>;
|
||||
if (card.type === 'cloze') return f.text ?? '';
|
||||
return f.front ?? '';
|
||||
}
|
||||
|
||||
function cardBack(card: Card): string {
|
||||
const f = card.fields as Record<string, string>;
|
||||
if (card.type === 'basic' || card.type === 'basic-reverse') return f.back ?? '';
|
||||
if (card.type === 'cloze') return f.extra ?? '';
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{deck ? t('print.title', { deck: deck.name }) : t('print.loading')} · Cardecky</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="screen-controls">
|
||||
<a href="/decks/{deckId}" class="back-link">{t('print.back')}</a>
|
||||
{#if !loading && !error}
|
||||
<span class="card-count">{t('print.n_cards', { n: cards.length })}</span>
|
||||
<button class="print-btn" onclick={() => window.print()}>
|
||||
{t('print.btn')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="screen-only status">{t('print.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="screen-only status error">{error}</p>
|
||||
{:else if deck}
|
||||
<div class="cards-list">
|
||||
{#each cards as card (card.id)}
|
||||
{@const front = cardFront(card)}
|
||||
{@const back = cardBack(card)}
|
||||
|
||||
<!-- Front side -->
|
||||
<div class="flash-card flash-card--front">
|
||||
<div class="card-label">{t('print.front')}</div>
|
||||
<div class="card-body">{front}</div>
|
||||
<div class="card-footer">{deck.name} · {card.type}</div>
|
||||
</div>
|
||||
|
||||
<!-- Back side -->
|
||||
<div class="flash-card flash-card--back">
|
||||
<div class="card-label">{t('print.back_side')}</div>
|
||||
<div class="card-body">{back || '—'}</div>
|
||||
<div class="card-footer">{deck.name}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Screen UI ─────────────────────────────────────── */
|
||||
|
||||
.screen-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.card-count {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.print-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 2rem 1.5rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.status.error {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
/* ── Cards grid (screen preview) ──────────────────── */
|
||||
|
||||
.cards-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 148mm);
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flash-card {
|
||||
width: 148mm;
|
||||
height: 105mm;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6mm 8mm 4mm;
|
||||
box-sizing: border-box;
|
||||
background: hsl(var(--color-card));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flash-card--back {
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-bottom: 3mm;
|
||||
}
|
||||
|
||||
.flash-card--back .card-label {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
font-size: 13pt;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
font-size: 7pt;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 3mm;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Print styles ──────────────────────────────────── */
|
||||
|
||||
@media print {
|
||||
.screen-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.screen-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cards-list {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flash-card {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A6 landscape;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<Tab>('anki');
|
||||
|
||||
onMount(() => {
|
||||
if (!devUser.id) {
|
||||
goto('/');
|
||||
|
|
@ -20,11 +25,25 @@
|
|||
<h1 class="text-2xl font-semibold">{t('import.title')}</h1>
|
||||
<p class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">{t('import.intro')}</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<AnkiImport />
|
||||
<!-- Tab bar -->
|
||||
<div class="mt-5 flex gap-1 border-b border-[hsl(var(--color-border))]">
|
||||
{#each (['anki', 'csv', 'quizlet'] as Tab[]) as tab (tab)}
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
class:tab-active={activeTab === tab}
|
||||
class:tab-inactive={activeTab !== tab}
|
||||
onclick={() => (activeTab = tab)}
|
||||
>
|
||||
{t(`import.tab_${tab}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<aside class="mt-8 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<div class="mt-5">
|
||||
{#if activeTab === 'anki'}
|
||||
<AnkiImport />
|
||||
|
||||
<aside class="mt-6 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<div class="mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.what_works_title')}</div>
|
||||
<ul class="list-disc pl-4">
|
||||
<li>{t('import.what_works_decks')}</li>
|
||||
|
|
@ -39,4 +58,42 @@
|
|||
<li>{t('import.what_skipped_addons')}</li>
|
||||
</ul>
|
||||
</aside>
|
||||
{:else if activeTab === 'csv'}
|
||||
<CsvImport />
|
||||
|
||||
<aside class="mt-6 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<div class="mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.csv_format_title')}</div>
|
||||
<pre class="mt-1 overflow-x-auto rounded bg-[hsl(var(--color-border))]/30 p-2 text-[10px] leading-relaxed">{t('import.csv_format_example')}</pre>
|
||||
<p class="mt-2">{t('import.csv_format_note')}</p>
|
||||
</aside>
|
||||
{:else}
|
||||
<QuizletImport />
|
||||
|
||||
<aside class="mt-6 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
<div class="mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.quizlet_how_title')}</div>
|
||||
<ol class="list-decimal pl-4 space-y-1">
|
||||
<li>{t('import.quizlet_how_1')}</li>
|
||||
<li>{t('import.quizlet_how_2')}</li>
|
||||
<li>{t('import.quizlet_how_3')}</li>
|
||||
</ol>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tab-active {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-bottom: 2px solid hsl(var(--color-primary));
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-inactive {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-inactive:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
94
docs/FEATURE_IDEAS.md
Normal file
94
docs/FEATURE_IDEAS.md
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue